diff --git a/.bun-version b/.bun-version index 9df886c42a..1c99cf0e80 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.4.2 +1.4.4 diff --git a/.gitattributes b/.gitattributes index 5aa13a6aaa..19f3e8920a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,28 +14,21 @@ apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt text eol=lf # statistics and collapses them in pull request diffs -- they are build output to # be regenerated, not code to review, and together they outweigh the hand-written # source. The attribute is GitHub-only: git, CI, and local diffs are unaffected. -# Collapsed is not hidden. v1-openapi.yaml and the lockfiles do carry changes -# worth reading -- an upstream API change, a dependency substitution -- and both -# stay one click away in review. -# Regeneration is owned by `go generate` in apps/cli-go and by the `generate` -# turbo task, whose `outputs` in turbo.json are the source of truth for this list. -# Only the generated files are listed, not their hand-written inputs -# (pkg/api/*.cfg.yaml, api/overlay.yaml). apps/docs/content/docs/commands/ is -# generated too but gitignored, so it needs no entry. -apps/cli-go/api/v1-openapi.yaml linguist-generated=true -apps/cli-go/pkg/api/*.gen.go linguist-generated=true +# Collapsed is not hidden. The lockfile does carry changes worth reading -- +# a dependency substitution -- and stays one click away in review. +# Regeneration is owned by the `generate` turbo task, whose `outputs` in +# turbo.json are the source of truth for this list. Only the generated files +# are listed, not their hand-written inputs. apps/docs/content/docs/commands/ +# is generated too but gitignored, so it needs no entry. packages/api/src/generated/* linguist-generated=true packages/api/scripts/openapi-source.json linguist-generated=true packages/stack/src/functions/generated/* text eol=lf linguist-generated=true apps/cli/src/shared/feedback/database.types.ts linguist-generated=true -apps/docs/public/cli/config.schema.json linguist-generated=true -apps/docs/public/cli/project-config.schema.json linguist-generated=true -apps/docs/public/cli/supabase-lock/v1.schema.json linguist-generated=true +apps/docs/public/schemas/** linguist-generated=true # Lockfiles. Collapsed rather than hidden -- a dependency change is still one # click away in review, which is the same treatment GitHub gives package-lock.json. pnpm-lock.yaml linguist-generated=true -apps/cli-go/go.sum linguist-generated=true # Not a language marker -- this is the service image manifest, not a container build. apps/cli/src/shared/services/Dockerfile linguist-detectable=false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e2ad6c2359..e328847024 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,15 +10,8 @@ /tools/*/package.json @supabase/cli @supabase-oss /pnpm-lock.yaml @supabase/cli @supabase-oss /pnpm-workspace.yaml @supabase/cli @supabase-oss -/apps/cli-go/go.mod @supabase/cli @supabase-oss -/apps/cli-go/go.sum @supabase/cli @supabase-oss -/apps/cli-go/pkg/go.mod @supabase/cli @supabase-oss -/apps/cli-go/pkg/go.sum @supabase/cli @supabase-oss /apps/cli/src/shared/services/Dockerfile @supabase/cli @supabase-oss -/apps/cli-go/pkg/config/templates/Dockerfile @supabase/cli @supabase-oss /packages/stack/src/Artifacts.ts @supabase/cli @supabase-oss -/apps/cli-go/api/v1-openapi.yaml @supabase/cli @supabase-oss -/apps/cli-go/pkg/api/*.gen.go @supabase/cli @supabase-oss /packages/api/src/generated/ @supabase/cli @supabase-oss /packages/api/scripts/openapi-source.json @supabase/cli @supabase-oss diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 9304447079..29edac9a27 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -107,7 +107,7 @@ body: id: ticket-id attributes: label: Crash report ID - description: If the CLI printed one after rerunning with `--create-ticket`, paste the crash report ID. + description: If you have a crash report or support ticket ID, paste it here. placeholder: ab1ac733e31e4f928a4d7c8402543712 validations: required: false diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 8e503fe8f6..d25fb9608b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,16 +14,10 @@ inputs: fails with a path validation error. required: false default: "true" - go-cache: - description: >- - Whether to restore the Go module and build caches. Disable this in jobs - that never run Go. - required: false - default: "true" tools: description: >- Space-separated mise tools to install instead of every tool in - mise.toml. Skipping the Go toolchain keeps the restored mise cache small. + mise.toml. required: false default: "" pnpm-store-cache: @@ -126,11 +120,10 @@ runs: commit: ${{ steps.sticky.outputs.commit }} - name: Install toolchains - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5.2.2 with: - version: 2026.9.0 + version: 2026.10.17 install_args: ${{ inputs.tools }} - cache_key: ${{ inputs.tools != '' && format('mise-{0}-{1}-{2}-{3}', runner.os, runner.arch, inputs.tools, hashFiles('mise.toml', 'mise.lock', '.node-version', '.bun-version', 'package.json')) || '' }} - name: Resolve pnpm store path if: inputs.dependency-cache == 'true' @@ -189,41 +182,6 @@ runs: path: ${{ steps.pnpm-cache.outputs.path }}/lockfile-verified.jsonl key: pnpm-verify-${{ runner.os }}-${{ inputs.dependency-firewall-token != '' && 'firewall' || 'public' }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} - - name: Resolve Go cache paths - if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' - id: go-cache - shell: bash - run: | - echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" - echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" - - - name: Mount Go module disk - if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' && steps.sticky.outputs.enabled == 'true' - uses: useblacksmith/stickydisk@b6cb58d1d31d6d32a25248fab12493d831f7bd48 # v1.7.2 - with: - key: ${{ github.repository }}-go-mod-v3-${{ runner.arch }} - path: ${{ steps.go-cache.outputs.mod }} - commit: ${{ steps.sticky.outputs.commit }} - - - name: Mount Go build disk - if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' && steps.sticky.outputs.enabled == 'true' - uses: useblacksmith/stickydisk@b6cb58d1d31d6d32a25248fab12493d831f7bd48 # v1.7.2 - with: - key: ${{ github.repository }}-go-build-v3-${{ runner.arch }} - path: ${{ steps.go-cache.outputs.build }} - commit: ${{ steps.sticky.outputs.commit }} - - - name: Configure Go dependency cache - if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' && steps.sticky.outputs.enabled != 'true' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ${{ steps.go-cache.outputs.mod }} - ${{ steps.go-cache.outputs.build }} - key: go-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/cli-go/go.mod', 'apps/cli-go/go.sum') }} - restore-keys: | - go-cache-${{ runner.os }}-${{ runner.arch }}- - - name: Install dependencies shell: bash env: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b66df12186..c24da2c9cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,28 +37,6 @@ updates: # — never bundle it into the actions-major group. cooldown: default-days: 7 - - package-ecosystem: "gomod" - directories: - - "/apps/cli-go" - - "/apps/cli-go/pkg" - schedule: - interval: "cron" - cronjob: "0 0 * * *" - commit-message: - prefix: "chore(cli-go): " - groups: - go-minor: - update-types: - - minor - - patch - exclude-patterns: - - github.com/compose-spec/compose-go/v2 - ignore: - - dependency-name: "github.com/compose-spec/compose-go/v2" - - dependency-name: "github.com/docker/cli" - - dependency-name: "github.com/docker/docker" - cooldown: - default-days: 7 - package-ecosystem: "npm" directory: "/" schedule: diff --git a/.github/jactionlint.yaml b/.github/jactionlint.yaml index 11d6ccd219..11746e71e1 100644 --- a/.github/jactionlint.yaml +++ b/.github/jactionlint.yaml @@ -1,4 +1,7 @@ -require-commit-hash: true # actions, reusable workflows and docker:// must be pinned to a full SHA / @sha256 digest +rules: + missing-timeout: off + concurrency-limits: off + unpinned-uses: error # actions, reusable workflows and docker:// must be pinned to a full SHA / @sha256 digest self-hosted-runner: labels: - arm-runner @@ -8,3 +11,8 @@ self-hosted-runner: - blacksmith-8vcpu-ubuntu-2404 - blacksmith-8vcpu-windows-2025 - blacksmith-32vcpu-ubuntu-2404 +paths: + # Other projects' repositories, pulled in as git submodules. CI never checks them out. + ".repos/**": + ignore: + - ".*" diff --git a/.github/scripts/ai-review/resolve.ts b/.github/scripts/ai-review/resolve.ts index 0cfaafc071..74984e22ed 100644 --- a/.github/scripts/ai-review/resolve.ts +++ b/.github/scripts/ai-review/resolve.ts @@ -12,7 +12,7 @@ * * `resolveDecision` is the pure orchestration function (I/O injected) a test * can drive without the network; `main()` wires up the real GitHub I/O, - * writes `should_run`/`pr_number`/`head_ref`/`trigger` to `$GITHUB_OUTPUT`, + * writes `should_run`/`pr_number`/`head_sha`/`trigger` to `$GITHUB_OUTPUT`, * and surfaces the skip reason (if any) in `$GITHUB_STEP_SUMMARY`. * * Run in CI as: `bun .github/scripts/ai-review/resolve.ts`. @@ -374,7 +374,6 @@ function writeOutputs(result: ResolveResult, prNumber: number): void { const entries: Record = { should_run: String(result.shouldRun), pr_number: String(prNumber), - head_ref: `refs/pull/${prNumber}/head`, head_sha: result.headSha ?? "", trigger: result.trigger, }; diff --git a/.github/scripts/cli-bench/github.ts b/.github/scripts/cli-bench/github.ts new file mode 100644 index 0000000000..4859009a68 --- /dev/null +++ b/.github/scripts/cli-bench/github.ts @@ -0,0 +1,69 @@ +/** Small GitHub REST helpers shared by the CLI benchmark scripts. */ + +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +/** A GitHub request that did not succeed, with the HTTP status and GitHub's own message. */ +export class GithubRequestError extends Error { + constructor( + readonly status: number, + readonly detail: string, + url: string, + ) { + super(`GitHub request failed (${status}) for ${url}`); + this.name = "GithubRequestError"; + } +} + +export async function githubFetch( + url: string, + token: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { message?: unknown }; + const detail = typeof payload.message === "string" ? payload.message : response.statusText; + throw new GithubRequestError(response.status, detail, url); + } + return response; +} + +export interface IssueComment { + id: number; + body: string; + authorLogin: string; +} + +/** Every comment on an issue or pull request, oldest first. */ +export async function listIssueComments( + token: string, + base: string, + issue: number, +): Promise> { + const comments: Array = []; + for (let page = 1; ; page++) { + const batch = (await ( + await githubFetch(`${base}/issues/${issue}/comments?per_page=100&page=${page}`, token) + ).json()) as Array<{ id: number; body: string | null; user: { login: string } | null }>; + comments.push( + ...batch.map((entry) => ({ + id: entry.id, + body: entry.body ?? "", + authorLogin: entry.user?.login ?? "", + })), + ); + if (batch.length < 100) return comments; + } +} diff --git a/.github/scripts/cli-bench/history.test.ts b/.github/scripts/cli-bench/history.test.ts new file mode 100644 index 0000000000..0d096bce41 --- /dev/null +++ b/.github/scripts/cli-bench/history.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { + decodeHistory, + headlineOf, + makeEntry, + MAX_PREVIOUS_RUNS, + mergeHistory, + renderHistory, + type RunEntry, +} from "./history.ts"; + +const entry = (n: number, overrides: Partial = {}): RunEntry => ({ + sha: n.toString(16).padStart(7, "0"), + at: "2026-10-28T09:05Z", + headline: "no changes found", + url: `https://github.com/o/r/actions/runs/${n}`, + ...overrides, +}); + +describe("headlineOf", () => { + test("reads the title of the rendered report", () => { + expect(headlineOf("\n\n## CLI benchmark: 2 things to check\n\nbody")).toBe( + "2 things to check", + ); + }); + + test("falls back when the report has no title", () => { + expect(headlineOf("nothing here")).toBe("unknown"); + }); +}); + +describe("makeEntry", () => { + test("shortens the commit and rounds the time down to the minute in UTC", () => { + const made = makeEntry({ + sha: "0123456789abcdef0123456789abcdef01234567", + now: new Date("2026-10-28T09:05:59Z"), + headline: "no changes found", + url: "https://github.com/o/r/actions/runs/9/attempts/2", + }); + + expect(made).toMatchObject({ sha: "0123456", at: "2026-10-28T09:05Z" }); + }); + + test("refuses a run link that is not a workflow run", () => { + const made = makeEntry({ + sha: "abcdef1", + now: new Date(), + headline: "h", + url: "https://evil.test/x", + }); + + expect(made).toBeUndefined(); + }); +}); + +describe("mergeHistory", () => { + test("puts the new run first", () => { + expect(mergeHistory([entry(1)], entry(2)).map((e) => e.sha)).toEqual([ + entry(2).sha, + entry(1).sha, + ]); + }); + + test("keeps the current run plus at most 20 earlier ones, dropping the oldest", () => { + const previous = Array.from({ length: 30 }, (_, i) => entry(i + 1)); + + const merged = mergeHistory(previous, entry(100)); + + expect(merged).toHaveLength(MAX_PREVIOUS_RUNS + 1); + expect(merged[0]).toEqual(entry(100)); + expect(merged.at(-1)).toEqual(entry(MAX_PREVIOUS_RUNS)); + }); +}); + +describe("renderHistory and decodeHistory", () => { + test("round-trips the entries through the hidden marker", () => { + const entries = [entry(3), entry(2), entry(1)]; + + expect(decodeHistory(`body\n${renderHistory(entries, (t) => t)}\n`)).toEqual(entries); + }); + + test("lists earlier runs newest first and leaves the current run out of the list", () => { + const rendered = renderHistory( + [entry(3), entry(2, { headline: "1 thing to check" })], + (t) => t, + ); + + expect(rendered).toContain("Previous runs"); + expect(rendered).toContain( + "- `0000002` · 2026-10-28 09:05 UTC · 1 thing to check · [run](https://github.com/o/r/actions/runs/2)", + ); + expect(rendered).not.toContain("0000003` ·"); + }); + + test("shows no list for a first run", () => { + expect(renderHistory([entry(1)], (t) => t)).not.toContain("Previous runs"); + }); + + test.each([ + ["no marker", "plain comment"], + ["not base64 json", ""], + ["wrong shape", ``], + [ + "a forged run link", + ``, + ], + [ + "too many entries", + ``, + ], + ])("treats %s as empty history", (_name, body) => { + expect(decodeHistory(body)).toEqual([]); + }); +}); diff --git a/.github/scripts/cli-bench/history.ts b/.github/scripts/cli-bench/history.ts new file mode 100644 index 0000000000..9602383119 --- /dev/null +++ b/.github/scripts/cli-bench/history.ts @@ -0,0 +1,106 @@ +/** + * Run history kept inside the benchmark comment. The newest run is the comment itself; earlier + * runs sit in a collapsed list, backed by a hidden marker so the next update can carry them on. + * The marker is read back only from the workflow bot's own comment, and anything that does not + * decode cleanly counts as no history. + */ + +/** Earlier runs kept below the current one. */ +export const MAX_PREVIOUS_RUNS = 20; +const HISTORY_MARKER = //u; +const MAX_ENCODED_CHARS = 30_000; +const MAX_HEADLINE_CHARS = 100; +const SHA = /^[0-9a-f]{7,40}$/u; +const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/u; +const RUN_URL = /^https:\/\/[^\s)<>]+\/actions\/runs\/\d+(?:\/attempts\/\d+)?$/u; + +export interface RunEntry { + /** Short commit SHA. */ + sha: string; + /** UTC time to the minute, `YYYY-MM-DDTHH:MMZ`. */ + at: string; + headline: string; + url: string; +} + +const isEntry = (value: unknown): value is RunEntry => { + if (typeof value !== "object" || value === null) return false; + const { sha, at, headline, url } = value as Record; + return ( + typeof sha === "string" && + SHA.test(sha) && + typeof at === "string" && + AT.test(at) && + typeof headline === "string" && + headline.length <= MAX_HEADLINE_CHARS && + !/[\r\n]/u.test(headline) && + typeof url === "string" && + RUN_URL.test(url) + ); +}; + +/** The title the renderer puts after `## CLI benchmark:`, flattened to one bounded line. */ +export function headlineOf(markdown: string): string { + const title = /^## CLI benchmark: (.+)$/mu.exec(markdown)?.[1] ?? "unknown"; + return title.replaceAll(/\s+/gu, " ").trim().slice(0, MAX_HEADLINE_CHARS); +} + +/** An entry for a run, or undefined when the commit or run link is not in the expected shape. */ +export function makeEntry(input: { + sha: string; + now: Date; + headline: string; + url: string; +}): RunEntry | undefined { + const entry = { + sha: input.sha.slice(0, 7), + at: `${input.now.toISOString().slice(0, 16)}Z`, + headline: input.headline, + url: input.url, + }; + return isEntry(entry) ? entry : undefined; +} + +/** The runs recorded in a comment body, newest first; empty when the marker is absent or invalid. */ +export function decodeHistory(body: string): Array { + const encoded = HISTORY_MARKER.exec(body)?.[1]; + if (encoded === undefined || encoded.length > MAX_ENCODED_CHARS) return []; + try { + const value: unknown = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + return Array.isArray(value) && value.length <= MAX_PREVIOUS_RUNS + 1 && value.every(isEntry) + ? value + : []; + } catch { + return []; + } +} + +/** The new run first, then the earlier ones, with at most {@link MAX_PREVIOUS_RUNS} behind it. */ +export function mergeHistory( + previous: ReadonlyArray, + current: RunEntry, +): Array { + return [current, ...previous].slice(0, MAX_PREVIOUS_RUNS + 1); +} + +const day = (at: string) => `${at.slice(0, 10)} ${at.slice(11, 16)} UTC`; + +/** + * The hidden marker for `entries` (current run first), preceded by a collapsed list of the + * earlier ones when there are any. `clean` makes a headline safe to render. + */ +export function renderHistory( + entries: ReadonlyArray, + clean: (text: string) => string, +): string { + const marker = ``; + const earlier = entries.slice(1); + if (earlier.length === 0) return marker; + const lines = earlier.map( + (entry) => + `- \`${entry.sha}\` · ${day(entry.at)} · ${clean(entry.headline)} · [run](${entry.url})`, + ); + return ["
Previous runs", "", ...lines, "", "
", marker].join( + "\n", + ); +} diff --git a/.github/scripts/cli-bench/post-comment.test.ts b/.github/scripts/cli-bench/post-comment.test.ts new file mode 100644 index 0000000000..fb890a8952 --- /dev/null +++ b/.github/scripts/cli-bench/post-comment.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { + BENCH_MARKER, + FAILURE_TITLE, + MAX_BODY_CHARS, + planComment, + prepareBody, + sanitize, +} from "./post-comment.ts"; +import { decodeHistory, type RunEntry } from "./history.ts"; + +const BOT = "github-actions[bot]"; + +describe("planComment", () => { + test("creates the comment when none carries the marker", () => { + expect(planComment("create-if-missing", [{ id: 1, body: "hi", authorLogin: BOT }])).toEqual({ + kind: "create", + }); + }); + + test("leaves an existing marker comment alone on a later push", () => { + const existing = { id: 5, body: `${BENCH_MARKER}\nold`, authorLogin: BOT }; + + expect(planComment("create-if-missing", [existing])).toEqual({ kind: "skip" }); + }); + + test("replaces a first-run failure notice on a later push", () => { + const failed = { + id: 5, + body: `${BENCH_MARKER}\n${FAILURE_TITLE}\nbuild failed`, + authorLogin: BOT, + }; + + expect(planComment("create-if-missing", [failed])).toEqual({ + kind: "update", + id: 5, + previousBody: failed.body, + }); + }); + + test("updates the marker comment in place for a re-run", () => { + const existing = { id: 5, body: `${BENCH_MARKER}\nold`, authorLogin: BOT }; + + expect(planComment("upsert", [existing])).toEqual({ + kind: "update", + id: 5, + previousBody: existing.body, + }); + }); + + test("ignores a marker pasted by someone else", () => { + const forged = { id: 6, body: BENCH_MARKER, authorLogin: "mallory" }; + + expect(planComment("create-if-missing", [forged])).toEqual({ kind: "create" }); + }); +}); + +describe("prepareBody", () => { + test("puts the marker first exactly once", () => { + const body = prepareBody(`text ${BENCH_MARKER} more`); + + expect(body.startsWith(`${BENCH_MARKER}\n`)).toBe(true); + expect(body.split(BENCH_MARKER)).toHaveLength(2); + }); + + test("cuts an oversized report below GitHub's limit and says so", () => { + const body = prepareBody("x".repeat(200_000)); + + expect(body.length).toBeLessThanOrEqual(MAX_BODY_CHARS); + expect(body).toContain("truncated"); + }); +}); + +describe("prepareBody history", () => { + const run = (n: number): RunEntry => ({ + sha: n.toString(16).padStart(7, "0"), + at: "2026-10-28T09:05Z", + headline: "no changes found", + url: `https://github.com/o/r/actions/runs/${n}`, + }); + + test("lists earlier runs after the run link and carries them in the marker", () => { + const body = prepareBody("report", "https://github.com/o/r/actions/runs/3", [run(3), run(2)]); + + expect(body.indexOf("[Workflow run]")).toBeLessThan(body.indexOf("Previous runs")); + expect(decodeHistory(body)).toEqual([run(3), run(2)]); + }); + + test("drops the oldest runs before cutting the report when the body is too long", () => { + const history = Array.from({ length: 21 }, (_, i) => run(21 - i)); + const report = "x".repeat(MAX_BODY_CHARS - 4_000); + + const body = prepareBody(report, "https://github.com/o/r/actions/runs/21", history); + + expect(body.length).toBeLessThanOrEqual(MAX_BODY_CHARS); + expect(body).not.toContain("truncated"); + const kept = decodeHistory(body); + expect(kept.length).toBeGreaterThan(0); + expect(kept.length).toBeLessThan(history.length); + expect(kept[0]).toEqual(history[0]); + }); + + test("renders an earlier run's headline through the sanitizer", () => { + const hostile = { ...run(1), headline: "@team " }; + + const body = prepareBody("report", undefined, [run(2), hostile]); + + expect(body).not.toContain(" { + test("neutralizes mentions, links, images and unknown HTML but keeps the report tags", () => { + const hostile = + "@org/team [x](https://evil.test) ![i](https://evil.test/a.png) @u [a][r] www.evil.test
"; + + const clean = sanitize(hostile); + + expect(clean).not.toMatch(/@(?!\u200b)/u); + expect(clean).not.toContain("@"); + expect(clean).not.toMatch(/(?"); + }); + + test("pins the failure title and marker literals the renderer emits", () => { + expect(FAILURE_TITLE).toBe("## CLI benchmark: could not run"); + expect(BENCH_MARKER).toBe(""); + }); +}); + +describe("prepareBody run link", () => { + test("appends the trusted run link after the report", () => { + const body = prepareBody("report", "https://github.com/o/r/actions/runs/1"); + + expect(body.trimEnd().endsWith("[Workflow run](https://github.com/o/r/actions/runs/1)")).toBe( + true, + ); + }); +}); diff --git a/.github/scripts/cli-bench/post-comment.ts b/.github/scripts/cli-bench/post-comment.ts new file mode 100644 index 0000000000..d46572ea3c --- /dev/null +++ b/.github/scripts/cli-bench/post-comment.ts @@ -0,0 +1,153 @@ +/** + * Posts the CLI benchmark summary as the pull request's single marker comment. + * + * Runs in the one job that can write comments and treats the rendered markdown as untrusted data + * (pull request code produced it): it reads a bounded regular file, neutralizes mentions, links and + * HTML, adds the marker and the run link itself, truncates to GitHub's body limit, and creates or + * updates the comment. Run in CI as: `bun .github/scripts/cli-bench/post-comment.ts`. + * + * Modes: `create-if-missing` leaves an existing marker comment alone unless it reports a failed + * run (first run on a pull request); `upsert` updates it in place or creates it (a re-run). + * Every post also records the run in the comment's history of earlier runs. + */ +import { lstatSync, readFileSync } from "node:fs"; + +import { githubFetch, listIssueComments, requireEnv, type IssueComment } from "./github.ts"; +import { decodeHistory, headlineOf, makeEntry, mergeHistory, renderHistory } from "./history.ts"; +import type { RunEntry } from "./history.ts"; + +/** Hidden tag identifying the benchmark comment; the renderer in `bench-summary.ts` emits the same text. */ +export const BENCH_MARKER = ""; +/** Login of comments posted with the workflow's `GITHUB_TOKEN`. */ +const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; +/** GitHub rejects comment bodies longer than 65536 characters. */ +export const MAX_BODY_CHARS = 65_000; +const TRUNCATION_NOTICE = + "\n\n
\n\n_The report was truncated; see the workflow run for the rest._\n"; + +export type Mode = "create-if-missing" | "upsert"; + +export type ExistingComment = IssueComment; + +export type CommentPlan = + | { kind: "skip" } + | { kind: "create" } + | { kind: "update"; id: number; previousBody: string }; + +const ALLOWED_TAGS = new Set(["
", "
", "", "", "
"]); +const ZERO_WIDTH_SPACE = "\u200b"; +/** Title the renderer gives a run that produced no comparison. */ +export const FAILURE_TITLE = "## CLI benchmark: could not run"; +export const MAX_FILE_BYTES = 1_000_000; + +/** Keeps mentions, links, images and raw HTML in untrusted markdown from rendering. */ +export function sanitize(markdown: string): string { + return markdown + .replaceAll("&", "&") + .replaceAll(/<[^>\n]*>?/gu, (tag) => + ALLOWED_TAGS.has(tag) ? tag : tag.replaceAll("<", "<"), + ) + .replaceAll("@", `@${ZERO_WIDTH_SPACE}`) + .replaceAll("://", `:${ZERO_WIDTH_SPACE}//`) + .replaceAll(/\b(www)\./giu, `$1${ZERO_WIDTH_SPACE}.`) + .replaceAll("[", "\\[") + .replaceAll("]", "\\]"); +} + +/** + * The comment body: the marker first, the sanitized report, the trusted run link, then the run + * history. `history` lists the current run first; the oldest runs are dropped until the body fits. + */ +export function prepareBody( + markdown: string, + runUrl?: string, + history: ReadonlyArray = [], +): string { + const link = runUrl === undefined || runUrl === "" ? "" : `\n\n[Workflow run](${runUrl})\n`; + const content = sanitize(markdown.replaceAll(BENCH_MARKER, "")).trim(); + const body = `${BENCH_MARKER}\n${content}\n`; + for (let kept = history.length; kept >= 1; kept--) { + const tail = `${link}\n${renderHistory(history.slice(0, kept), sanitize)}\n`; + if (body.length + tail.length <= MAX_BODY_CHARS) return body + tail; + } + const tail = `${link}\n${history.length === 0 ? "" : `${renderHistory(history.slice(0, 1), sanitize)}\n`}`; + const limit = MAX_BODY_CHARS - tail.length; + const fitted = + body.length <= limit + ? body + : body.slice(0, limit - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE; + return fitted + tail; +} + +/** The workflow bot's benchmark comment, if any. Only its marker counts. */ +export function findBenchComment( + comments: ReadonlyArray, +): ExistingComment | undefined { + return comments.find( + (comment) => comment.authorLogin === WORKFLOW_BOT_LOGIN && comment.body.includes(BENCH_MARKER), + ); +} + +/** What to do with the pull request's benchmark comment. */ +export function planComment(mode: Mode, comments: ReadonlyArray): CommentPlan { + const existing = findBenchComment(comments); + if (existing === undefined) return { kind: "create" }; + // A first run that could not compare anything must not block the next push's real result. + const replaceable = mode === "upsert" || existing.body.includes(FAILURE_TITLE); + return replaceable + ? { kind: "update", id: existing.id, previousBody: existing.body } + : { kind: "skip" }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const base = `https://api.github.com/repos/${requireEnv("GITHUB_REPOSITORY")}`; + const pr = Number(requireEnv("PR_NUMBER")); + const mode = requireEnv("MODE"); + if (mode !== "create-if-missing" && mode !== "upsert") { + throw new Error(`Unsupported MODE "${mode}".`); + } + const file = requireEnv("BODY_FILE"); + const stat = lstatSync(file); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) { + throw new Error("The benchmark comment file is not a regular file under the size limit."); + } + const markdown = readFileSync(file, "utf8"); + const runUrl = process.env.RUN_URL; + const entry = makeEntry({ + sha: requireEnv("HEAD_SHA"), + now: new Date(), + headline: headlineOf(markdown), + url: runUrl ?? "", + }); + + const plan = planComment(mode, await listIssueComments(token, base, pr)); + const earlier = plan.kind === "update" ? decodeHistory(plan.previousBody) : []; + const body = prepareBody( + markdown, + runUrl, + entry === undefined ? [] : mergeHistory(earlier, entry), + ); + if (plan.kind === "skip") { + console.log(`PR #${pr} already has a benchmark comment; leaving it untouched.`); + } else if (plan.kind === "update") { + await githubFetch(`${base}/issues/comments/${plan.id}`, token, { + method: "PATCH", + body: JSON.stringify({ body }), + }); + console.log(`Updated benchmark comment ${plan.id} on PR #${pr}.`); + } else { + await githubFetch(`${base}/issues/${pr}/comments`, token, { + method: "POST", + body: JSON.stringify({ body }), + }); + console.log(`Created benchmark comment on PR #${pr}.`); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/scripts/cli-bench/rerun.test.ts b/.github/scripts/cli-bench/rerun.test.ts new file mode 100644 index 0000000000..fa37cad648 --- /dev/null +++ b/.github/scripts/cli-bench/rerun.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import { GithubRequestError } from "./github.ts"; +import { + type BenchPr, + type CommandIo, + handleBenchCommand, + RERUN_WINDOW_MS, + type WorkflowRun, +} from "./rerun.ts"; + +const REPO = "supabase/cli"; +const HEAD = "a".repeat(40); +const NOW = new Date("2026-10-28T12:00:00Z"); +const pr = (overrides: Partial = {}): BenchPr => ({ + state: "open", + draft: false, + headSha: HEAD, + headRepoFullName: REPO, + baseRepoFullName: REPO, + baseRef: "develop", + ...overrides, +}); +const run = (overrides: Partial = {}): WorkflowRun => ({ + id: 11, + status: "completed", + createdAt: "2026-10-27T12:00:00Z", + ...overrides, +}); + +function setup( + options: { + permissions?: Record; + pr?: BenchPr; + runs?: ReadonlyArray; + rerunError?: Error; + } = {}, +) { + const calls = { + reactions: [] as Array, + replies: [] as Array, + reruns: [] as Array, + }; + const io: CommandIo = { + fetchPr: async () => options.pr ?? pr(), + fetchPermission: async (login) => (options.permissions ?? { maintainer: "write" })[login], + listRuns: async () => options.runs ?? [run()], + rerun: async (id) => { + if (options.rerunError !== undefined) throw options.rerunError; + calls.reruns.push(id); + }, + reactToComment: async (id) => { + calls.reactions.push(id); + }, + replyToPr: async (_n, body) => { + calls.replies.push(body); + }, + }; + return { io, calls }; +} + +const command = (body: string, association = "COLLABORATOR", login = "maintainer") => ({ + prNumber: 7, + comment: { id: 99, authorLogin: login, authorAssociation: association, body }, + now: NOW, +}); + +describe("handleBenchCommand", () => { + test("re-runs the latest run for the head commit and reacts only after GitHub accepts it", async () => { + const { io, calls } = setup({ + runs: [run({ id: 1, createdAt: "2026-10-20T00:00:00Z" }), run({ id: 2 })], + }); + + const outcome = await handleBenchCommand(command("/bench\n"), io); + + expect(outcome).toEqual({ kind: "rerun", runId: 2 }); + expect(calls.reruns).toEqual([2]); + expect(calls.reactions).toEqual([99]); + expect(calls.replies).toEqual([]); + }); + + test.each(["/bench full", "/benchmark", "please /bench", "/Bench"])( + "ignores %p without a reply", + async (body) => { + const { io, calls } = setup(); + + const outcome = await handleBenchCommand(command(body), io); + + expect(outcome.kind).toBe("ignored"); + expect(calls).toEqual({ reactions: [], replies: [], reruns: [] }); + }, + ); + + test("rejects a commenter without write permission even as a MEMBER", async () => { + const { io, calls } = setup({ permissions: { maintainer: "read" } }); + + const outcome = await handleBenchCommand(command("/bench", "MEMBER"), io); + + expect(outcome).toMatchObject({ kind: "ignored" }); + expect(calls.reruns).toEqual([]); + }); + + test("lets the repository owner run it without a permission lookup result", async () => { + const { io } = setup({ permissions: {} }); + + expect((await handleBenchCommand(command("/bench", "OWNER"), io)).kind).toBe("rerun"); + }); + + test.each([ + ["a closed pull request", pr({ state: "closed" })], + ["a fork", pr({ headRepoFullName: "someone/cli" })], + ["a deleted head repository", pr({ headRepoFullName: "" })], + ["a pull request into another branch", pr({ baseRef: "feature/stack-base" })], + ])("ignores /bench on %s", async (_name, head) => { + const { io, calls } = setup({ pr: head }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("ignored"); + expect(calls).toEqual({ reactions: [], replies: [], reruns: [] }); + }); + + test("tells the pull request there is no run when none exists for the head commit", async () => { + const { io, calls } = setup({ runs: [] }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("replied"); + expect(calls.replies).toHaveLength(1); + expect(calls.replies[0]).toContain("no benchmark run for commit `aaaaaaa`"); + expect(calls.reruns).toEqual([]); + expect(calls.reactions).toEqual([]); + }); + + test("says a benchmark is already running instead of re-running it", async () => { + const { io, calls } = setup({ runs: [run({ status: "in_progress" })] }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("already running"); + expect(calls.reruns).toEqual([]); + expect(calls.reactions).toEqual([]); + }); + + test("says the re-run could not start when the run is older than 30 days", async () => { + const old = new Date(NOW.getTime() - RERUN_WINDOW_MS - 1000).toISOString(); + const { io, calls } = setup({ runs: [run({ createdAt: old })] }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("older than 30 days"); + expect(calls.reruns).toEqual([]); + }); + + test("surfaces GitHub's refusal in a reply and does not react", async () => { + const { io, calls } = setup({ + rerunError: new GithubRequestError(403, "Resource not accessible by integration", "u"), + }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("replied"); + expect(calls.replies[0]).toContain("403"); + expect(calls.replies[0]).toContain("Resource not accessible by integration"); + expect(calls.reactions).toEqual([]); + }); + + test("keeps an accepted re-run when the reaction fails", async () => { + const { io } = setup(); + io.reactToComment = async () => { + throw new Error("boom"); + }; + + expect(await handleBenchCommand(command("/bench"), io)).toEqual({ kind: "rerun", runId: 11 }); + }); + + test("explains that a draft pull request is not benchmarked", async () => { + const { io, calls } = setup({ pr: pr({ draft: true }) }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("draft"); + expect(calls.reruns).toEqual([]); + }); +}); diff --git a/.github/scripts/cli-bench/rerun.ts b/.github/scripts/cli-bench/rerun.ts new file mode 100644 index 0000000000..b0020185b8 --- /dev/null +++ b/.github/scripts/cli-bench/rerun.ts @@ -0,0 +1,231 @@ +/** + * `/bench` handler: re-runs the benchmark workflow run of a pull request's current head commit. + * + * Runs default branch code and never checks out or runs pull request code. The commenter must be + * the repository owner or hold write access, and the pull request must be an open, ready one from + * this repository into `develop`. The command re-runs the existing `pull_request` run, which + * benchmarks and updates the comment; the pull request gets a reply when no run can be re-run. + * Run in CI as: `bun .github/scripts/cli-bench/rerun.ts`. + */ +import { appendFileSync } from "node:fs"; + +import { fetchAuthorPermission, WRITE_PERMISSIONS } from "../contribution-gate.ts"; +import { githubFetch, GithubRequestError, requireEnv } from "./github.ts"; + +export const BENCH_COMMAND = "/bench"; +/** Base branches the benchmark runs for; keep in sync with the workflow's `branches:` filter. */ +export const BENCH_BASE_BRANCHES: ReadonlyArray = ["develop"]; +/** The workflow file whose runs `/bench` re-runs. */ +export const WORKFLOW_FILE = "cli-bench.yml"; +/** GitHub refuses to re-run a workflow run older than this. */ +export const RERUN_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +export interface BenchPr { + state: "open" | "closed"; + draft: boolean; + headSha: string; + /** `owner/name` of the head repository, empty when it was deleted. */ + headRepoFullName: string; + baseRepoFullName: string; + baseRef: string; +} + +export interface WorkflowRun { + id: number; + status: string; + createdAt: string; +} + +export interface CommandInput { + prNumber: number; + comment: { id: number; authorLogin: string; authorAssociation: string; body: string }; + now: Date; +} + +export interface CommandIo { + fetchPr: (prNumber: number) => Promise; + fetchPermission: (login: string) => Promise; + /** Runs of the benchmark workflow triggered by `pull_request` for this commit, any order. */ + listRuns: (headSha: string) => Promise>; + rerun: (runId: number) => Promise; + reactToComment: (commentId: number) => Promise; + replyToPr: (prNumber: number, body: string) => Promise; +} + +export type CommandOutcome = + | { kind: "ignored"; reason: string } + | { kind: "replied"; reason: string } + | { kind: "rerun"; runId: number }; + +const short = (sha: string) => sha.slice(0, 7); + +/** A GitHub error message as inline code: one short line with no backticks. */ +const quoted = (detail: string) => + `\`${detail.replaceAll("`", "'").replaceAll(/\s+/gu, " ").trim().slice(0, 200)}\``; + +export const replies = { + noRun: (sha: string) => + `There is no benchmark run for commit \`${short(sha)}\`. Runs start on a push that changes the ` + + "CLI, its packages, or the benchmark workflow, and GitHub's path filter looks at only the " + + "first 300 changed files, so this pull request may not have one.", + running: (sha: string) => + `A benchmark is already running for commit \`${short(sha)}\`. The comment updates when it finishes.`, + draft: () => `This pull request is a draft. The benchmark runs once it is ready for review.`, + expired: (sha: string) => + `The benchmark could not be re-run for commit \`${short(sha)}\`: its run is older than 30 days. ` + + "Push a new commit to benchmark again.", + refused: (sha: string, status: number, detail: string) => + `The benchmark could not be re-run for commit \`${short(sha)}\`: GitHub refused the request ` + + `(${status} ${quoted(detail)}).`, +}; + +/** Handles one `/bench` comment; a rejected command is ignored without a reply. */ +export async function handleBenchCommand( + input: CommandInput, + io: CommandIo, +): Promise { + const { comment, prNumber } = input; + // The workflow's `if:` only pre-filters on `startsWith`, so `/bench full` must be rejected here. + if (comment.body.trim() !== BENCH_COMMAND) { + return { kind: "ignored", reason: `Comment is not exactly ${BENCH_COMMAND}.` }; + } + // Only the repository OWNER skips the lookup: MEMBER and COLLABORATOR do not imply push access. + const permission = await io.fetchPermission(comment.authorLogin); + const authorized = + comment.authorAssociation === "OWNER" || + (permission !== undefined && WRITE_PERMISSIONS.has(permission)); + if (!authorized) { + return { + kind: "ignored", + reason: + `Commenter @${comment.authorLogin} is not authorized to run ${BENCH_COMMAND} ` + + `(author_association=${comment.authorAssociation}, permission=${permission ?? "n/a"}).`, + }; + } + const pr = await io.fetchPr(prNumber); + if (pr.state === "closed") return { kind: "ignored", reason: `PR #${prNumber} is closed.` }; + if (pr.headRepoFullName !== pr.baseRepoFullName) { + return { + kind: "ignored", + reason: `${BENCH_COMMAND} runs only on pull requests from this repository.`, + }; + } + if (!BENCH_BASE_BRANCHES.includes(pr.baseRef)) { + return { + kind: "ignored", + reason: `${BENCH_COMMAND} runs only on pull requests into ${BENCH_BASE_BRANCHES.join(" or ")}.`, + }; + } + + const reply = async (text: string): Promise => { + await io.replyToPr(prNumber, text); + return { kind: "replied", reason: text }; + }; + if (pr.draft) return reply(replies.draft()); + + const latest = [...(await io.listRuns(pr.headSha))].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + )[0]; + if (latest === undefined) return reply(replies.noRun(pr.headSha)); + if (latest.status !== "completed") return reply(replies.running(pr.headSha)); + if (input.now.getTime() - Date.parse(latest.createdAt) > RERUN_WINDOW_MS) { + return reply(replies.expired(pr.headSha)); + } + + try { + await io.rerun(latest.id); + } catch (error) { + if (error instanceof GithubRequestError) { + return reply(replies.refused(pr.headSha, error.status, error.detail)); + } + throw error; + } + // Cosmetic: a failed reaction must never hide an accepted re-run. + try { + await io.reactToComment(comment.id); + } catch (error) { + console.warn(`Could not react to comment ${comment.id}: ${String(error)}`); + } + return { kind: "rerun", runId: latest.id }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const [owner = "", repo = ""] = requireEnv("GITHUB_REPOSITORY").split("/"); + const base = `https://api.github.com/repos/${owner}/${repo}`; + const prNumber = Number(requireEnv("PR_NUMBER")); + + const io: CommandIo = { + fetchPr: async (n) => { + const value = (await (await githubFetch(`${base}/pulls/${n}`, token)).json()) as { + state: "open" | "closed"; + draft: boolean; + head: { sha: string; repo: { full_name: string } | null }; + base: { ref: string; repo: { full_name: string } }; + }; + return { + state: value.state, + draft: value.draft, + headSha: value.head.sha, + headRepoFullName: value.head.repo?.full_name ?? "", + baseRepoFullName: value.base.repo.full_name, + baseRef: value.base.ref, + }; + }, + fetchPermission: (login) => fetchAuthorPermission(token, owner, repo, login), + listRuns: async (headSha) => { + const url = + `${base}/actions/workflows/${WORKFLOW_FILE}/runs` + + `?event=pull_request&head_sha=${headSha}&per_page=100`; + const payload = (await (await githubFetch(url, token)).json()) as { + workflow_runs: Array<{ id: number; status: string; created_at: string; head_sha: string }>; + }; + return payload.workflow_runs + .filter((run) => run.head_sha === headSha) + .map((run) => ({ id: run.id, status: run.status, createdAt: run.created_at })); + }, + rerun: async (runId) => { + await githubFetch(`${base}/actions/runs/${runId}/rerun`, token, { method: "POST" }); + }, + reactToComment: async (commentId) => { + await githubFetch(`${base}/issues/comments/${commentId}/reactions`, token, { + method: "POST", + body: JSON.stringify({ content: "eyes" }), + }); + }, + replyToPr: async (n, body) => { + await githubFetch(`${base}/issues/${n}/comments`, token, { + method: "POST", + body: JSON.stringify({ body }), + }); + }, + }; + + const outcome = await handleBenchCommand( + { + prNumber, + comment: { + id: Number(requireEnv("COMMENT_ID")), + authorLogin: requireEnv("COMMENT_AUTHOR_LOGIN"), + authorAssociation: requireEnv("COMMENT_AUTHOR_ASSOCIATION"), + body: requireEnv("COMMENT_BODY"), + }, + now: new Date(), + }, + io, + ); + const message = + outcome.kind === "rerun" + ? `Re-running benchmark run ${outcome.runId} for PR #${prNumber}.` + : `${outcome.kind === "replied" ? "Replied on" : "Ignored command on"} PR #${prNumber}: ${outcome.reason}`; + console.log(message); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (summaryFile) appendFileSync(summaryFile, `${message}\n`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/scripts/cli-bench/resolve.test.ts b/.github/scripts/cli-bench/resolve.test.ts new file mode 100644 index 0000000000..7ac55287b2 --- /dev/null +++ b/.github/scripts/cli-bench/resolve.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { BENCH_MARKER, FAILURE_TITLE } from "./post-comment.ts"; +import { resolveBench, SKIP_REASON } from "./resolve.ts"; + +const BOT = "github-actions[bot]"; +const reported = { + id: 5, + body: `${BENCH_MARKER}\n## CLI benchmark: no changes found`, + authorLogin: BOT, +}; + +describe("resolveBench", () => { + test("benchmarks the first push of a pull request that has no comment yet", () => { + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [] }).shouldRun).toBe(true); + }); + + test("skips a later push while the comment reports a result, and says how to refresh it", () => { + const result = resolveBench({ runAttempt: 1, isFork: false, comments: [reported] }); + + expect(result).toEqual({ shouldRun: false, skipReason: SKIP_REASON }); + expect(SKIP_REASON).toContain("`/bench`"); + }); + + test("benchmarks again when the comment reports a failed run", () => { + const failed = { ...reported, body: `${BENCH_MARKER}\n${FAILURE_TITLE}` }; + + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [failed] }).shouldRun).toBe(true); + }); + + test("benchmarks every re-run even when the comment reports a result", () => { + expect(resolveBench({ runAttempt: 2, isFork: false, comments: [reported] }).shouldRun).toBe( + true, + ); + }); + + test("ignores a marker pasted by someone else", () => { + const forged = { ...reported, authorLogin: "mallory" }; + + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [forged] }).shouldRun).toBe(true); + }); + + test("benchmarks every push of a fork pull request, which never has a comment", () => { + expect(resolveBench({ runAttempt: 1, isFork: true, comments: [reported] }).shouldRun).toBe( + true, + ); + }); +}); diff --git a/.github/scripts/cli-bench/resolve.ts b/.github/scripts/cli-bench/resolve.ts new file mode 100644 index 0000000000..03882d2450 --- /dev/null +++ b/.github/scripts/cli-bench/resolve.ts @@ -0,0 +1,68 @@ +/** + * CLI benchmark resolver for `pull_request` runs: decides whether the benchmark job runs. + * + * The first run on a pull request benchmarks and comments. Later pushes skip while the comment + * exists and reports a result; a re-run (`run_attempt` above 1), a missing comment, a failed first + * run, or a fork pull request, which never has a comment, benchmarks. Needs read access only. + * Run in CI as: `bun .github/scripts/cli-bench/resolve.ts`. + */ +import { appendFileSync } from "node:fs"; + +import { listIssueComments, requireEnv, type IssueComment } from "./github.ts"; +import { FAILURE_TITLE, findBenchComment } from "./post-comment.ts"; + +export interface ResolveInput { + runAttempt: number; + isFork: boolean; + comments: ReadonlyArray; +} + +export interface ResolveResult { + shouldRun: boolean; + /** Shown in the job summary when the benchmark is skipped. */ + skipReason?: string; +} + +export const SKIP_REASON = + "A benchmark comment already exists on this pull request. Comment `/bench` to refresh it."; + +/** Skips only a first attempt on a pull request whose benchmark comment reports a result. */ +export function resolveBench(input: ResolveInput): ResolveResult { + if (input.isFork || input.runAttempt > 1) return { shouldRun: true }; + const existing = findBenchComment(input.comments); + if (existing === undefined || existing.body.includes(FAILURE_TITLE)) return { shouldRun: true }; + return { shouldRun: false, skipReason: SKIP_REASON }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const base = `https://api.github.com/repos/${requireEnv("GITHUB_REPOSITORY")}`; + const prNumber = Number(requireEnv("PR_NUMBER")); + const runAttempt = Number(requireEnv("RUN_ATTEMPT")); + const isFork = requireEnv("HEAD_REPO") !== requireEnv("GITHUB_REPOSITORY"); + + let comments: ReadonlyArray = []; + if (!isFork && runAttempt === 1) { + // A comment that cannot be listed must not hide a benchmark: run it instead. + comments = await listIssueComments(token, base, prNumber).catch((error: unknown) => { + console.warn(`Could not list comments on PR #${prNumber}: ${String(error)}`); + return []; + }); + } + + const result = resolveBench({ runAttempt, isFork, comments }); + console.log( + `CLI benchmark resolve for PR #${prNumber}: should_run=${result.shouldRun} ` + + `attempt=${runAttempt} fork=${isFork}`, + ); + appendFileSync(requireEnv("GITHUB_OUTPUT"), `should_run=${result.shouldRun}\n`); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (result.skipReason && summaryFile) appendFileSync(summaryFile, `${result.skipReason}\n`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/scripts/flaky-check/cli.ts b/.github/scripts/flaky-check/cli.ts index 979fa17b77..0079bda6ea 100644 --- a/.github/scripts/flaky-check/cli.ts +++ b/.github/scripts/flaky-check/cli.ts @@ -112,9 +112,6 @@ async function runCommand(): Promise { entry.suite === "focused" && filters.length === 0 ? ["--changed", capture(["git", "merge-base", "HEAD", `origin/${env("BASE_REF")}`])] : filters; - if (entry.suite === "e2e") { - process.env.SUPABASE_GO_BINARY = join(process.cwd(), "apps/cli-go/supabase-go"); - } if (entry.suite === "e2e" && entry.target === "cli") { // Matches test.yml, so concurrency-sensitive flakes show up here too. process.env.SUPABASE_E2E_WORKERS = "4"; diff --git a/.github/scripts/flaky-check/plan.test.ts b/.github/scripts/flaky-check/plan.test.ts index 83c752ecb3..0a963da7b6 100644 --- a/.github/scripts/flaky-check/plan.test.ts +++ b/.github/scripts/flaky-check/plan.test.ts @@ -101,10 +101,7 @@ describe("commandsFor", () => { const invocations = commandsFor(entry("flaky-focused-run1"), 3, ["--changed", "abc123"]); expect(invocations.map((i) => [i.reports, outputOf(i.argv)])).toEqual([ - [ - { turbo: ["test:unit:run", "--filter=!@supabase/cli-go"], report: "unit.3" }, - ".flaky-results/unit.3.json", - ], + [{ turbo: ["test:unit:run"], report: "unit.3" }, ".flaky-results/unit.3.json"], [ { turbo: ["test:integration:run", "--filter=!@supabase/api"], report: "integration.3" }, ".flaky-results/integration.3.json", diff --git a/.github/scripts/flaky-check/plan.ts b/.github/scripts/flaky-check/plan.ts index df37d4e705..e30f5a44c7 100644 --- a/.github/scripts/flaky-check/plan.ts +++ b/.github/scripts/flaky-check/plan.ts @@ -218,12 +218,7 @@ function api(runtime: string[], project: string, report: string, extra: string[] } function unit(iteration: number, extra: string[]): Invocation[] { - return [ - turbo(["test:unit:run", "--filter=!@supabase/cli-go"], `unit.${iteration}`, [ - "--passWithNoTests", - ...extra, - ]), - ]; + return [turbo(["test:unit:run"], `unit.${iteration}`, ["--passWithNoTests", ...extra])]; } // @supabase/api chains a Node and a Bun Vitest run in one script, and Turbo forwards arguments diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 582874175f..56bbfb6926 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -16,6 +16,10 @@ on: description: "PR number to review" required: true type: string + # A `/ai-review` comment on a pull request starts a review. The resolve job checks who + # commented, and the one job that can write to the pull request runs code from the default + # branch only, never from the pull request. + # jactionlint ignore=dangerous-triggers issue_comment: types: - created @@ -65,7 +69,6 @@ jobs: outputs: should_run: ${{ steps.resolve.outputs.should_run }} pr_number: ${{ steps.resolve.outputs.pr_number }} - head_ref: ${{ steps.resolve.outputs.head_ref }} # Every job reviews this commit, so a push during the run is never reported as reviewed. head_sha: ${{ steps.resolve.outputs.head_sha }} trigger: ${{ steps.resolve.outputs.trigger }} @@ -161,7 +164,10 @@ jobs: # behavior mid-rollout. Installed from the trusted checkout with npm config isolation so a # PR-supplied `.npmrc` in the untrusted `pr` checkout can never redirect this install to a # hostile registry. - # 2.1.280 is the first release that accepts claude-opus-5-5. + # 2.1.280 is the first release that accepts claude-opus-5-5. The CLI is installed on its + # own, not through the repository's lockfile, so nothing in the pull request's checkout + # can change what gets installed. + # jactionlint ignore=adhoc-packages - name: Install Claude Code CLI working-directory: trusted run: | @@ -544,7 +550,7 @@ jobs: steps: # Security-critical: this is the only job with write permission, so it must only ever # execute trusted base-branch code — never the PR head. Checking out `develop` explicitly - # (never `needs.resolve.outputs.head_ref`) keeps a malicious PR from smuggling a script + # (never the PR's own ref) keeps a malicious PR from smuggling a script # change into the one job that can write back to the PR. (For `pull_request` events GitHub # runs the workflow file from the PR's own ref; acceptable since the auto path only admits # same-repo PRs, whose authors hold write access anyway, and fork PRs run with a read-only diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index b9858b3428..831ec66cf7 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -27,13 +27,14 @@ jobs: outputs: authorized: ${{ steps.check.outputs.authorized }} steps: - # App token needs org membership read, repo write to edit the release, and PR write to - # comment and close. + # The token reads the approver's team membership and comments on the pull request. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-members: read + permission-pull-requests: write - name: Authorize approver against supabase/cli team id: check @@ -66,11 +67,16 @@ jobs: contents: write pull-requests: write steps: + # The token edits the GitHub release, deletes the branch, and comments on and closes the + # pull request. The script below runs from the pull request's own checkout, so the token + # must not be able to change workflow files. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write # Checkout the PR head so reviewer edits made before approval are captured; # apply-release-notes.ts reads from the working tree. @@ -99,7 +105,7 @@ jobs: exit 1 fi echo "==> Applying notes for $tag" - pnpm exec bun apps/cli/scripts/apply-release-notes.ts --tag "$tag" + bun apps/cli/scripts/apply-release-notes.ts --tag "$tag" release_url="https://github.com/${{ github.repository }}/releases/tag/${tag}" gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ "Applied to [${tag}](${release_url}) after approval by @${APPROVER}." diff --git a/.github/workflows/backfill-release-notes.yml b/.github/workflows/backfill-release-notes.yml index 3fc78c04c8..914e59ddef 100644 --- a/.github/workflows/backfill-release-notes.yml +++ b/.github/workflows/backfill-release-notes.yml @@ -39,7 +39,7 @@ jobs: backfill: runs-on: ubuntu-latest timeout-minutes: 30 - continue-on-error: ${{ inputs.non_blocking }} + continue-on-error: ${{ inputs.non_blocking || false }} permissions: contents: write env: diff --git a/.github/workflows/bot-pr-auto-merge.yml b/.github/workflows/bot-pr-auto-merge.yml index 6745993567..3d1006bfda 100644 --- a/.github/workflows/bot-pr-auto-merge.yml +++ b/.github/workflows/bot-pr-auto-merge.yml @@ -1,6 +1,9 @@ name: Bot PR auto-merge on: + # Only this event lets the job use the auto-approve environment's secret. The job never + # checks out or runs code from the pull request. + # jactionlint ignore=dangerous-triggers pull_request_target: types: [opened, reopened, synchronize, ready_for_review] branches: [develop] @@ -14,6 +17,9 @@ jobs: # Other apps (coding agents, CI vendors) also open PRs here and still need a maintainer. # Release-notes PRs are published by a maintainer approval and must never merge. Branch sync # PRs land by fast-forward on a maintainer approval, never by a squash. + # The first condition checks who opened the pull request. The second checks who caused this + # event, so a person pushing to a bot's pull request does not get it approved. + # jactionlint ignore=bot-conditions if: >- contains(fromJSON('["dependabot[bot]", "supabase-cli-releaser[bot]"]'), github.event.pull_request.user.login) && contains(fromJSON('["dependabot[bot]", "supabase-cli-releaser[bot]"]'), github.event.sender.login) && diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 2e8c2a32db..a66ca86419 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -71,10 +71,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Pre-download Go modules - working-directory: apps/cli-go - run: go mod download -x - - name: Install nfpm env: NFPM_VERSION: "2.47.0" @@ -104,14 +100,15 @@ jobs: rcodesign --version - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" + run: bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - name: Build CLI artifacts timeout-minutes: ${{ inputs.build_timeout_minutes }} - run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" + run: bun apps/cli/scripts/build.ts --version "${VERSION}" - name: Verify build artifacts run: | + set -euo pipefail for pkg in cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-musl cli-windows-arm64 cli-windows-x64; do echo "Checking packages/$pkg/bin/..." ls -la "packages/$pkg/bin/" @@ -119,6 +116,39 @@ jobs: echo "Checking dist/..." ls -la dist/ + # Every channel ships a single `supabase` binary; no `supabase-go` companion. + shopt -s nullglob + for f in packages/*/bin/supabase-go*; do + echo "::error::${f} is a supabase-go companion binary" + exit 1 + done + + check_listing() { + local artifact="$1" + shift + local listing + if ! listing="$("$@")"; then + echo "::error::failed to list the contents of ${artifact}" + exit 1 + fi + if grep -q 'supabase-go' <<<"$listing"; then + echo "::error::${artifact} contains supabase-go" + exit 1 + fi + } + + for a in dist/*.tar.gz dist/*.apk; do check_listing "$a" tar -tzf "$a"; done + for a in dist/*.zip; do check_listing "$a" unzip -Z1 "$a"; done + for a in dist/*.deb; do check_listing "$a" dpkg-deb -c "$a"; done + rpms=(dist/*.rpm) + if [[ ${#rpms[@]} -gt 0 ]]; then + if command -v rpm >/dev/null; then + for a in "${rpms[@]}"; do check_listing "$a" rpm -qlp "$a"; done + else + echo "::notice::rpm is not installed on this runner; rpm contents were not checked for supabase-go" + fi + fi + - name: Check existing build artifacts cache id: build-artifacts-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/cli-bench.yml b/.github/workflows/cli-bench.yml new file mode 100644 index 0000000000..73f76a6ac5 --- /dev/null +++ b/.github/workflows/cli-bench.yml @@ -0,0 +1,301 @@ +name: CLI benchmark +run-name: "CLI benchmark #${{ github.event.pull_request.number || github.event.issue.number }}" + +# Report-only comparison of a pull request's CLI build with a build of its merge base. Every +# workload runs against the replay server, so no Docker, database, or network is involved. The +# job never blocks a pull request and is not a required check. +# +# The first push to a pull request from this repository benchmarks and posts one comment tagged +# ``; later pushes skip while that comment reports a result. A maintainer +# commenting exactly `/bench` re-runs the run for the pull request's current head commit, as does +# "Re-run all jobs" in the Actions UI: a re-run benchmarks again and updates the comment, keeping +# earlier runs in a collapsed list. Pull requests from forks benchmark every push and write a job +# summary only. A pull request that changes no benchmarked path, or more than the 300 files +# GitHub's path filter inspects, has no run to re-run, and `/bench` says so. +# See apps/cli/docs/tracing-monitoring.md. +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - develop + paths: + - "apps/cli/**" + - "apps/cli-e2e/**" + - "packages/**" + - "pnpm-lock.yaml" + - "turbo.json" + - ".github/workflows/cli-bench.yml" + - ".github/scripts/cli-bench/**" + # `/bench` runs default branch code only: it never checks out or runs pull request code. + # jactionlint ignore=dangerous-triggers + issue_comment: + types: + - created + +permissions: {} + +# Comment runs get a group of their own, so a comment can never cancel a pull request's benchmark +# nor be cancelled by one. +concurrency: + group: >- + cli-bench-${{ github.event.pull_request.number || github.event.issue.number }}${{ + github.event_name == 'issue_comment' && format('-comment-{0}', github.run_id) || '' }} + cancel-in-progress: true + +jobs: + # Decides whether this push benchmarks. Read-only: it only lists the pull request's comments. + resolve: + name: Resolve + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.resolve.outputs.should_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Resolve + id: resolve + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: bun .github/scripts/cli-bench/resolve.ts + + # `/bench`: re-runs this workflow's run for the pull request's current head commit. The only job + # with `actions: write`; it runs default branch code and no pull request code. + rerun: + name: Re-run benchmark + # Cheap, non-authoritative pre-filter; rerun.ts does the exact command match and the + # effective-permission lookup. + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/bench') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Re-run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_AUTHOR_LOGIN: ${{ github.event.comment.user.login }} + COMMENT_AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} + COMMENT_BODY: ${{ github.event.comment.body }} + run: bun .github/scripts/cli-bench/rerun.ts + + bench: + name: Benchmark CLI against merge base + needs: resolve + if: needs.resolve.outputs.should_run == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Prepare benchmark directory + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/bench" + echo "BENCH_DIR=$RUNNER_TEMP/bench" >> "$GITHUB_ENV" + + - name: Build branch binary + id: branch_build + continue-on-error: true + shell: bash + run: | + pnpm --filter supabase run build:binary + mkdir -p "$BENCH_DIR/branch" + cp apps/cli/dist/supabase "$BENCH_DIR/branch/supabase" + + - name: Build merge-base binary + id: base_build + continue-on-error: true + if: steps.branch_build.outcome == 'success' + env: + HUSKY: "0" + BASE_REF: ${{ github.event.pull_request.base.ref }} + shell: bash + run: | + merge_base="$(git merge-base HEAD "origin/$BASE_REF")" + echo "Merge base: $merge_base" + echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT" + git worktree add "$RUNNER_TEMP/base-src" "$merge_base" + cd "$RUNNER_TEMP/base-src" + # The setup action writes this file only when the Dependency Firewall token is available. + if [ -f "$RUNNER_TEMP/dependency-firewall.npmrc" ]; then + export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/dependency-firewall.npmrc" + fi + pnpm install --frozen-lockfile + pnpm --filter supabase run build:binary + mkdir -p "$BENCH_DIR/base" + cp apps/cli/dist/supabase "$BENCH_DIR/base/supabase" + + - name: Start replay server + id: replay + continue-on-error: true + if: steps.branch_build.outcome == 'success' && steps.base_build.outcome == 'success' + shell: bash + run: | + bun apps/cli-e2e/scripts/replay-serve.ts > "$BENCH_DIR/replay.log" 2>&1 & + echo "REPLAY_PID=$!" >> "$GITHUB_ENV" + for _ in $(seq 1 60); do + url="$(grep -o 'http://[^"]*' "$BENCH_DIR/replay.log" | head -n 1 || true)" + if [ -n "$url" ]; then + echo "REPLAY_URL=$url" >> "$GITHUB_ENV" + exit 0 + fi + sleep 0.5 + done + echo "replay server did not report a URL" >&2 + cat "$BENCH_DIR/replay.log" >&2 + exit 1 + + - name: Run benchmark + id: bench + continue-on-error: true + if: steps.replay.outcome == 'success' + shell: bash + run: | + bun apps/cli/scripts/bench-cli.ts \ + --base "$BENCH_DIR/base/supabase" \ + --branch "$BENCH_DIR/branch/supabase" \ + --workloads apps/cli/bench/workloads.pr.json \ + --api-url "$REPLAY_URL" \ + --trace \ + --out "$BENCH_DIR/report.json" | tee "$BENCH_DIR/report.txt" + + - name: Stop replay server + if: always() && env.REPLAY_PID != '' + shell: bash + run: kill "$REPLAY_PID" || true + + - name: Write job summary + if: always() + env: + BRANCH_BUILD: ${{ steps.branch_build.outcome == 'success' && 'ok' || 'failed' }} + BASE_BUILD: ${{ steps.base_build.outcome == 'success' && 'ok' || 'failed' }} + REPLAY: ${{ steps.replay.outcome == 'success' && 'ok' || 'failed' }} + MERGE_BASE: ${{ steps.base_build.outputs.merge_base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + shell: bash + run: | + bun apps/cli/scripts/bench-summary.ts \ + --report "$BENCH_DIR/report.json" \ + --branch-build "$BRANCH_BUILD" \ + --base-build "$BASE_BUILD" \ + --replay "$REPLAY" \ + --head-sha "$HEAD_SHA" \ + --base-ref "$BASE_REF" \ + --merge-base "$MERGE_BASE" \ + --comment-out "$BENCH_DIR/comment.md" + + - name: Upload report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-bench-report + path: | + ${{ runner.temp }}/bench/report.json + ${{ runner.temp }}/bench/report.txt + retention-days: 14 + if-no-files-found: ignore + + - name: Upload comment + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-bench-comment + path: ${{ runner.temp }}/bench/comment.md + retention-days: 1 + if-no-files-found: ignore + + comment: + name: Post pull request comment + needs: + - resolve + - bench + # A pull_request run from a fork has a read-only token, so it posts no comment. Failing to + # post never fails the workflow. + if: >- + ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && needs.bench.result == 'success' && + github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + # The only pull_request job that can write to the pull request. It checks out the merge commit, + # which its author can already run with this token by editing the workflow. It never builds or + # runs the benchmarked binaries and reads the rendered markdown as data. + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Download comment + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-bench-comment + path: ${{ runner.temp }}/bench-comment + - name: Post comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MODE: ${{ github.run_attempt > 1 && 'upsert' || 'create-if-missing' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BODY_FILE: ${{ runner.temp }}/bench-comment/comment.md + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + run: | + if [ ! -f "$BODY_FILE" ]; then + echo "The benchmark job produced no comment." + exit 0 + fi + bun .github/scripts/cli-bench/post-comment.ts diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml deleted file mode 100644 index 9ab33fd002..0000000000 --- a/.github/workflows/cli-go-api-sync.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: API Sync - -on: - repository_dispatch: - types: - - api-sync - # Fallback for a lost dispatch, per docs/openapi-sync.md. This matters more - # now that the per-pull-request Codegen check reads the committed snapshot: - # this workflow is the only thing that looks at the live specification, so a - # dropped dispatch would otherwise leave drift undetected indefinitely. - schedule: - - cron: "17 6 * * *" - workflow_dispatch: # allow manual triggering - -permissions: - contents: read - -jobs: - sync: - name: Sync API Types - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - - # The only place that reads the live spec. Every other consumer, including - # the per-PR Codegen check, regenerates from the committed snapshot, so - # upstream drift turns into this one pull request instead of a failure on - # every open pull request that touches apps/cli-go. - - name: Refresh the OpenAPI snapshot - run: curl -fsSL "$API_SPEC_URL" -o api/v1-openapi.yaml - env: - API_SPEC_URL: https://api.supabase.green/api/v1-yaml - - - name: Run codegen - run: go generate - - # Covers the snapshot as well as the generated client: an upstream edit - # that codegen ignores (a description, an example) would otherwise be - # refetched and discarded on every run, leaving the snapshot stale. The - # snapshot is compared byte-for-byte for the same reason -- under - # --ignore-space-at-eol a trailing-whitespace-only upstream edit would be - # refetched and discarded forever, which is the loop this check prevents. - # pkg keeps the flag: it is oapi-codegen output, not upstream bytes. - - name: Check for changes - id: check - run: | - if git diff --exit-code --quiet api/v1-openapi.yaml && - git diff --ignore-space-at-eol --exit-code --quiet pkg; then - echo "No changes detected" - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected" - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Generate token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.GH_APP_CLIENT_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permission-pull-requests: write - permission-contents: write - - - name: Create Pull Request - if: steps.check.outputs.has_changes == 'true' - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ steps.app-token.outputs.token }} - commit-message: "chore: sync API types from infrastructure" - title: "chore: sync API types from infrastructure" - body: | - This PR was automatically created to sync API types from the infrastructure repository. - - Changes were detected after refreshing `apps/cli-go/api/v1-openapi.yaml` from the latest spec from infrastructure, and `apps/cli-go/pkg/api` is regenerated from it. The snapshot diff is the upstream API change and is worth reading: it is marked `linguist-generated`, so GitHub collapses it behind a "Load diff" click rather than hiding it. - branch: sync/api-types - base: develop -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml deleted file mode 100644 index a4e60bfab4..0000000000 --- a/.github/workflows/cli-go-ci.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: CI - -on: - pull_request: - paths: - - apps/cli-go/** - merge_group: - push: - branches: - - develop - paths: - - apps/cli-go/** - -permissions: - contents: read - -jobs: - test: - name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - - # Required by: internal/utils/credentials/keyring_test.go - - uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 - - run: | - pkgs=$(go list ./pkg/... | grep -Ev 'pkg/api' | paste -sd ',' -) - go tool gotestsum -- -race -v -count=1 ./... \ - -coverpkg="./cmd/...,./internal/...,${pkgs}" -coverprofile=coverage.out - - # internal/start (docker-compose/v2, buildx, moby/buildkit, k8s client-go, - # aws-sdk-go-v2, notary, secret-detector) was deleted outright as - # unreachable (CLI-1966) -- this guards against silently reintroducing - # that dependency tree via some other package, since nothing else checks - # the shipped binary's size on every change. - - name: Verify the heavy orchestration dependency tree stays out of the module - run: | - set -euo pipefail - go list -deps . > /tmp/deps.txt - ! grep -Eq \ - 'docker/compose|docker/buildx|moby/buildkit|k8s\.io/client-go|aws-sdk-go-v2' \ - /tmp/deps.txt - - - name: Check existing coverage cache - id: coverage-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: apps/cli-go/coverage.out - key: cli-go-coverage-${{ github.run_id }}-v1 - lookup-only: true - - - name: Save coverage cache - if: steps.coverage-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: apps/cli-go/coverage.out - key: cli-go-coverage-${{ github.run_id }}-v1 - - coverage: - name: Coverage - needs: - - test - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - name: Restore coverage cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: apps/cli-go/coverage.out - key: cli-go-coverage-${{ github.run_id }}-v1 - fail-on-cache-miss: true - - name: Move coverage report - run: cp apps/cli-go/coverage.out coverage.out - working-directory: . - - uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 - with: - file: coverage.out - format: golang - - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - with: - version: 2026.9.0 - install: true - install_args: >- - go - golangci-lint - - - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 - with: - args: --timeout 5m --verbose - install-mode: none - only-new-issues: true - working-directory: apps/cli-go - - codegen: - name: Codegen - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - - # Regenerates from the committed api/v1-openapi.yaml snapshot, so this - # check is hermetic: it fails only when a pull request leaves pkg/api out - # of sync with the snapshot it was generated from. It deliberately does - # not compare against the live upstream spec -- doing so made unrelated - # pull requests fail together whenever staging drifted ahead of the - # committed snapshot. That drift is the API Sync workflow's job. - - run: go generate - - run: | - if ! git diff --ignore-space-at-eol --exit-code --quiet pkg; then - echo "Detected uncommitted changes after codegen. See status below:" - git diff - exit 1 - fi -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml deleted file mode 100644 index a1f9f3a33a..0000000000 --- a/.github/workflows/cli-go-mirror.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Mirror Dependencies -# We mirror upstream container images like Migra, imgproxy, etc. because these -# are usually only available on certain image registry and not others (e.g. only -# on Docker Hub and not on ghcr.io or AWS ECR). -# -# For container images that we control, we usually publish to Docker Hub, -# ghcr.io, and AWS ECR. - -on: - # This workflow is the manual/bulk entry point for re-mirroring everything. - # Template image bumps are mirrored automatically by mirror-template-images.yml - # on push to develop, which backfills any unmirrored tag when the templates - # Dockerfile changes — so anyone pinning SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io - # doesn't inherit a `manifest unknown` failure. (No CI job pins that registry - # anymore -- see mirror-template-images.yml's own comment for why, CLI-1966.) - workflow_dispatch: -permissions: - contents: read - -jobs: - setup: - runs-on: blacksmith-2vcpu-ubuntu-2404 - outputs: - tags: ${{ steps.list.outputs.tags }} - curr: ${{ steps.curr.outputs.tags }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - - id: list - run: | - echo "tags=$(go run tools/listdep/main.go)" >> "$GITHUB_OUTPUT" - - id: curr - name: List main branch dependencies - if: github.ref != 'refs/heads/main' - run: | - git fetch origin main - git checkout main - echo "tags=$(go run tools/listdep/main.go)" >> "$GITHUB_OUTPUT" - - publish: - needs: - - setup - permissions: - contents: read - packages: write - id-token: write - if: ${{ needs.setup.outputs.tags != needs.setup.outputs.curr }} - strategy: - matrix: - src: ${{ fromJson(needs.setup.outputs.tags) }} - # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/cli-go-mirror-image.yml - with: - image: ${{ matrix.src }} - secrets: - PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-tag-pkg.yml b/.github/workflows/cli-go-tag-pkg.yml deleted file mode 100644 index 9185ad5771..0000000000 --- a/.github/workflows/cli-go-tag-pkg.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Tag pkg - -on: - workflow_dispatch: - inputs: - version: - description: "pkg version to tag (e.g. v1.2.2)" - required: true - type: string - -permissions: - contents: write - -jobs: - tag: - name: Create pkg tag - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: develop - fetch-depth: 0 - persist-credentials: false - - - name: Create and push pkg tag - run: | - VERSION="${INPUTS_VERSION}" - if ! [[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "Error: version '$VERSION' does not match semver format (e.g. v1.2.2)" - exit 1 - fi - TAG="pkg/$VERSION" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: tag '$TAG' already exists" - exit 1 - fi - git tag "$TAG" - git push origin "$TAG" - env: - INPUTS_VERSION: ${{ inputs.version }} -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/close-stale-issues-and-prs.yml b/.github/workflows/close-stale-issues-and-prs.yml index e6ecb519a3..fb43466983 100644 --- a/.github/workflows/close-stale-issues-and-prs.yml +++ b/.github/workflows/close-stale-issues-and-prs.yml @@ -22,10 +22,7 @@ on: type: string default: "security,pinned,do-not-close,keep-open,do not merge" -permissions: - contents: read - issues: write - pull-requests: write +permissions: {} concurrency: group: close-stale-issues-and-prs @@ -35,6 +32,10 @@ jobs: close-stale: runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: read + issues: write + pull-requests: write steps: - name: Close stale issues and PRs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/codeql.yml similarity index 55% rename from .github/workflows/cli-go-codeql.yml rename to .github/workflows/codeql.yml index 3f19507312..a25cab48b7 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,21 +2,19 @@ name: "CodeQL" on: pull_request: - paths: - - apps/cli-go/** - merge_group: push: branches: - develop - paths: - - apps/cli-go/** + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: analyze: name: Analyze (${{ matrix.language }}) - # Swift requires a macOS runner; every other language in the matrix uses blacksmith. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'blacksmith-8vcpu-ubuntu-2404' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 360 permissions: # required for all workflows security-events: write @@ -32,8 +30,6 @@ jobs: fail-fast: false matrix: include: - - language: go - build-mode: autobuild - language: javascript-typescript build-mode: none steps: @@ -48,21 +44,7 @@ jobs: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # Placeholder manual-build step; only runs if a language's build-mode above is set to "manual". - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml index af7cab6ac5..341bea8b29 100644 --- a/.github/workflows/contribution-gate.yml +++ b/.github/workflows/contribution-gate.yml @@ -1,6 +1,9 @@ name: Contribution Gate on: + # The gate has to comment on pull requests from forks, which only this event's token can do. + # It checks out the base branch, never the pull request. + # jactionlint ignore=dangerous-triggers pull_request_target: types: - opened @@ -16,10 +19,7 @@ on: type: boolean default: false -permissions: - pull-requests: write - issues: read - contents: read +permissions: {} concurrency: # Per-PR for pull_request_target; unique per run for manual sweeps (which @@ -31,6 +31,10 @@ jobs: gate-single: name: Contribution Gate runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: read + contents: read # Exempt maintainers/collaborators and bots. External contributors are gated. if: > github.event_name == 'pull_request_target' && @@ -42,6 +46,8 @@ jobs: # Checks out the base repo (default for pull_request_target), so the gate # runs trusted code from the base branch, never the untrusted PR head. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ".bun-version" @@ -58,11 +64,17 @@ jobs: gate-all: name: Contribution Gate (all open PRs) runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: read + contents: read if: github.event_name == 'workflow_dispatch' steps: # Base-branch checkout only — the sweep makes API calls and never runs # any PR's code. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ".bun-version" diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index ed5aa0f22a..0fc4f0a9f9 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -37,6 +37,7 @@ jobs: CLI_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | + set -o pipefail echo "Dispatching cli-e2e-ci live run for PR #${PR_NUMBER} @ ${CLI_SHA}" # Build the nested client_payload with jq — `gh api -f` sends a flat # body and would not nest `client_payload.*` correctly. diff --git a/.github/workflows/fill-sticky-disks.yml b/.github/workflows/fill-sticky-disks.yml index 2440f388d1..cfa187f3f7 100644 --- a/.github/workflows/fill-sticky-disks.yml +++ b/.github/workflows/fill-sticky-disks.yml @@ -1,6 +1,6 @@ name: Fill sticky disks -# Test jobs mount the pnpm, Go and Docker image caches from Blacksmith sticky +# Test jobs mount the pnpm and Docker image caches from Blacksmith sticky # disks but pull request runs never commit them. This workflow is the trusted # writer: it runs on protected branches, which are the only refs Blacksmith's # sticky-disk branch protection lets commit. @@ -56,7 +56,6 @@ jobs: # Starting a default project pulls every image `supabase start` uses. - name: Pull CLI images env: - SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go SUPABASE_CLI_BINARY_OVERRIDE: ${{ github.workspace }}/apps/cli/dist/supabase run: | project="$(mktemp -d)" @@ -72,6 +71,7 @@ jobs: - name: Pull stack package images run: | + set -o pipefail pnpm --silent --filter @supabase/stack prepare:test-artifacts --images | xargs --max-procs 4 --max-args 1 docker pull --quiet @@ -79,6 +79,7 @@ jobs: - name: Prune Docker before saving its image disk if: always() run: | + set -o pipefail docker ps --all --quiet | xargs --no-run-if-empty docker rm --force docker system prune --force docker volume prune --all --force diff --git a/.github/workflows/flaky-check.yml b/.github/workflows/flaky-check.yml index 73a547d9a1..7b8ae01850 100644 --- a/.github/workflows/flaky-check.yml +++ b/.github/workflows/flaky-check.yml @@ -76,12 +76,9 @@ jobs: persist-credentials: false - name: Install Bun - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - version: 2026.9.0 - install: true - install_args: bun - working_directory: .flaky-tooling + bun-version-file: .flaky-tooling/.bun-version - name: Plan runs id: plan @@ -142,7 +139,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - go-cache: ${{ matrix.suite == 'e2e' }} docker-images: ${{ matrix.suite != 'unit' && (matrix.suite != 'stack-e2e' || matrix.runtime == 'docker' || matrix.scenario == 'lifecycle' && matrix.runtime == 'native') }} native-artifacts: ${{ matrix.runtime == 'native' }} @@ -204,12 +200,9 @@ jobs: persist-credentials: false - name: Install Bun - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - version: 2026.9.0 - install: true - install_args: bun - working_directory: .flaky-tooling + bun-version-file: .flaky-tooling/.bun-version - name: Download results uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/github-scripts-ci.yml b/.github/workflows/github-scripts-ci.yml index 37a5c6cf43..55484ff381 100644 --- a/.github/workflows/github-scripts-ci.yml +++ b/.github/workflows/github-scripts-ci.yml @@ -34,7 +34,7 @@ jobs: test: name: Test and type-check runs-on: ubuntu-latest - # The shared setup installs the full workspace + Go toolchain via mise, which + # The shared setup installs the full workspace toolchain via mise, which # runs ~9-10 min; a 10-minute cap raced the install and got cancelled on a # cold cache. 20 gives that install headroom. (This check is heavier than it # needs to be for two scripts — slimming the setup is a possible follow-up.) diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index e69d6bd814..e93acdf712 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -52,7 +52,6 @@ jobs: api cli cli-e2e - cli-go cli-test-helpers config docs diff --git a/.github/workflows/live-e2e-notify.yml b/.github/workflows/live-e2e-notify.yml index 66edf219b7..441f130f15 100644 --- a/.github/workflows/live-e2e-notify.yml +++ b/.github/workflows/live-e2e-notify.yml @@ -1,6 +1,8 @@ name: Live E2E Notifications on: + # The job only reads whether the run passed. It runs nothing the run produced. + # jactionlint ignore=dangerous-triggers workflow_run: workflows: [Live E2E, Release] types: [completed] diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/mirror-image.yml similarity index 94% rename from .github/workflows/cli-go-mirror-image.yml rename to .github/workflows/mirror-image.yml index 889ecaf9ae..284118c964 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/mirror-image.yml @@ -19,14 +19,15 @@ on: required: true type: string -permissions: - contents: read - packages: write - id-token: write +permissions: {} jobs: mirror: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write steps: - id: strip env: diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index dabc95430e..06b044abae 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -6,7 +6,7 @@ name: Mirror template images # Dockerfile changes on develop — most often via a merged catalog bump or # hotfix PR from slim-release-published.yml, or a Dependabot `docker` bump for # an upstream-only image — this workflow detects any tag that is not yet -# mirrored and backfills it the same way `cli-go-mirror-image.yml` does. +# mirrored and backfills it the same way `mirror-image.yml` does. # # It runs on `push` to develop (not on the PR) on purpose: mirroring needs the # AWS role + packages:write, which a dependabot-triggered `pull_request` run @@ -14,10 +14,9 @@ name: Mirror template images # backfill runs as soon as the bump lands on develop, repopulating ghcr.io/ECR # so anyone pinning SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io doesn't inherit a # `manifest unknown` failure. (No CI job pins that registry anymore -- the -# `cli-go-ci.yml` `Start` job that used to made it CI-observable was removed in -# CLI-1966 along with `internal/start`; this mirror now only serves users who -# opt into the mirror directly, and a mirroring failure no longer turns any -# check red.) +# `Start` job that used to make it CI-observable was removed in CLI-1966 along +# with `internal/start`; this mirror now only serves users who opt into the +# mirror directly, and a mirroring failure no longer turns any check red.) on: push: @@ -41,6 +40,7 @@ jobs: permissions: contents: read packages: read + id-token: write outputs: missing: ${{ steps.detect.outputs.missing }} steps: @@ -54,6 +54,19 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + # Anonymous public.ecr.aws pulls share a per-IP data quota with every + # other GitHub-hosted runner user, which is often exhausted already. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ secrets.PROD_AWS_ROLE }} + aws-region: us-east-1 + + - name: Log in to public.ecr.aws + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: public.ecr.aws + - name: Log in to ghcr.io uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: @@ -66,7 +79,7 @@ jobs: # are skipped, so a re-run produces an empty list. - name: Detect images missing from the mirror id: detect - run: pnpm exec bun apps/cli/scripts/detect-unmirrored-images.ts + run: bun apps/cli/scripts/detect-unmirrored-images.ts mirror: name: Mirror image @@ -81,7 +94,7 @@ jobs: matrix: image: ${{ fromJson(needs.detect.outputs.missing) }} # Reuse the existing mirror logic (docker.io -> public.ecr.aws + ghcr.io). - uses: ./.github/workflows/cli-go-mirror-image.yml + uses: ./.github/workflows/mirror-image.yml with: image: ${{ matrix.image }} secrets: diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml index c459fdcf8c..3ba3ad70fc 100644 --- a/.github/workflows/propose-release-notes.yml +++ b/.github/workflows/propose-release-notes.yml @@ -61,6 +61,9 @@ jobs: with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + permission-issues: read + permission-pull-requests: write - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: @@ -82,4 +85,4 @@ jobs: - name: Propose release notes env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: pnpm exec bun apps/cli/scripts/propose-release-notes.ts --tag "${TAG}" --apply + run: bun apps/cli/scripts/propose-release-notes.ts --tag "${TAG}" --apply diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/publish-migra.yml similarity index 94% rename from .github/workflows/cli-go-publish-migra.yml rename to .github/workflows/publish-migra.yml index c3b0a56333..ac061f0d3c 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/publish-migra.yml @@ -38,8 +38,6 @@ jobs: arch: arm64 runs-on: ${{ matrix.runner }} timeout-minutes: 180 - outputs: - image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -88,11 +86,8 @@ jobs: packages: write id-token: write # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/cli-go-mirror-image.yml + uses: ./.github/workflows/mirror-image.yml with: image: ${{ needs.settings.outputs.image_tag }} secrets: PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/publish-pg-prove.yml similarity index 94% rename from .github/workflows/cli-go-pg-prove.yml rename to .github/workflows/publish-pg-prove.yml index 6a4414ee3f..f2b1ad1c07 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/publish-pg-prove.yml @@ -38,8 +38,6 @@ jobs: arch: arm64 runs-on: ${{ matrix.runner }} timeout-minutes: 180 - outputs: - image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -88,11 +86,8 @@ jobs: packages: write id-token: write # Call workflow explicitly because events from actions cannot trigger more actions - uses: ./.github/workflows/cli-go-mirror-image.yml + uses: ./.github/workflows/mirror-image.yml with: image: ${{ needs.settings.outputs.image_tag }} secrets: PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} -defaults: - run: - working-directory: apps/cli-go diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 73e2477257..9f5ce2057f 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -82,7 +82,7 @@ jobs: - name: Prepare package files run: | set -euo pipefail - pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${PREVIEW_VERSION}" + bun apps/cli/scripts/sync-versions.ts --version "${PREVIEW_VERSION}" pnpm --dir apps/cli build:shim find packages -path '*/bin/supabase*' -type f -exec chmod +x {} + diff --git a/.github/workflows/release-config.yml b/.github/workflows/release-config.yml index 3ab2d2ac0a..87e6d5e57b 100644 --- a/.github/workflows/release-config.yml +++ b/.github/workflows/release-config.yml @@ -91,7 +91,7 @@ jobs: resume_token="$(openssl rand -hex 16)" echo "::stop-commands::${resume_token}" trap 'echo "::${resume_token}::"' EXIT - pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md" + bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md" # Build/gate/pack also run on private-blocked pushes (should_release=false, version set): # if `private` is ever flipped back on, the plan half of the release train is already @@ -127,7 +127,7 @@ jobs: mkdir -p "$RUNNER_TEMP/config-release-gate-local" tar -xzf "$RUNNER_TEMP/config-release-artifact/supabase-config-${VERSION}.tgz" \ -C "$RUNNER_TEMP/config-release-gate-local" --no-same-owner --no-same-permissions - pnpm exec bun tools/config-release-gate.ts --version "$VERSION" \ + bun tools/config-release-gate.ts --version "$VERSION" \ --local-dist "$RUNNER_TEMP/config-release-gate-local/package/dist" - name: Upload release artifact @@ -355,19 +355,24 @@ jobs: fi echo "dist-tag ${NPM_TAG} -> ${VERSION} confirmed." + # The CLI's install scripts and setup-cli resolve releases/latest/download/..., so a + # config release must never become the repo's "latest" release. Re-runs skip this step + # when the release already exists. - name: Create GitHub Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - token: ${{ github.token }} - tag_name: config-v${{ needs.plan.outputs.version }} - name: "@supabase/config v${{ needs.plan.outputs.version }}" - body_path: ${{ runner.temp }}/config-release/config-release-notes.md - draft: false - prerelease: false - # The CLI's install scripts and setup-cli resolve - # releases/latest/download/..., so a config release must never - # become the repo's "latest" release. - make_latest: "false" + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="config-v${VERSION}" + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists; skipping." + else + gh release create "${tag}" \ + --verify-tag \ + --title "@supabase/config v${VERSION}" \ + --notes-file "${RUNNER_TEMP}/config-release/config-release-notes.md" \ + --latest=false + fi # Pages Slack the moment a real run arms the config-release approval gate. Runs in parallel # with the publish job's `waiting` state (needs only `plan`), so the ping and the pending diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 50f3f57213..32bde58beb 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -307,6 +307,8 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + # The cache key includes this run's id, so only this run's build job can have written it. + # jactionlint ignore=cache-poisoning - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -318,10 +320,10 @@ jobs: fail-on-cache-miss: true - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" + run: bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - name: Publish to npm - run: pnpm exec bun apps/cli/scripts/publish.ts --tag "${NPM_TAG}" + run: bun apps/cli/scripts/publish.ts --tag "${NPM_TAG}" - name: Configure git for release pushes run: | @@ -377,25 +379,35 @@ jobs: done # Created empty; the next step uploads the assets so each one can be retried on its own. + # Re-runs skip this step when the release already exists. - name: Create draft GitHub Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - tag_name: v${{ inputs.version }} - name: v${{ inputs.version }} - draft: true - prerelease: ${{ inputs.prerelease }} + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + tag="v${VERSION}" + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists; skipping." + else + args=(--draft --verify-tag --title "${tag}") + if [[ "$PRERELEASE" == true ]]; then + args+=(--prerelease) + fi + gh release create "${tag}" "${args[@]}" + fi # One asset at a time with per-asset retries; see apps/cli/scripts/upload-release-assets.ts. - name: Upload release assets env: GH_TOKEN: ${{ github.token }} - run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" + run: bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" # Publishing makes the release immutable, so require every expected asset to be uploaded first. - name: Verify release assets env: GH_TOKEN: ${{ github.token }} - run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" + run: bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" - name: Publish GitHub Release (immutable) env: @@ -434,7 +446,7 @@ jobs: backfill-release-notes: uses: ./.github/workflows/backfill-release-notes.yml needs: publish - if: ${{ !inputs.dry_run && needs.publish.result == 'success' }} + if: ${{ !cancelled() && !inputs.dry_run && needs.publish.result == 'success' }} with: tag: v${{ inputs.version }} apply: true @@ -460,7 +472,7 @@ jobs: publish-homebrew: needs: publish - if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} + if: ${{ !cancelled() && !inputs.dry_run && inputs.publish_brew_scoop && needs.publish.result == 'success' }} # github-hosted to share a cache store with build-github/publish, whose # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest @@ -514,14 +526,14 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Update Homebrew formula - run: pnpm exec bun apps/cli/scripts/update-homebrew.ts --version "${VERSION}" --name "${BREW_NAME}" + run: bun apps/cli/scripts/update-homebrew.ts --version "${VERSION}" --name "${BREW_NAME}" env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} publish-scoop: needs: publish - if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} + if: ${{ !cancelled() && !inputs.dry_run && inputs.publish_brew_scoop && needs.publish.result == 'success' }} # github-hosted to share a cache store with build-github/publish, whose # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest @@ -575,7 +587,7 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Update Scoop manifest - run: pnpm exec bun apps/cli/scripts/update-scoop.ts --version "${VERSION}" --name "${SCOOP_NAME}" + run: bun apps/cli/scripts/update-scoop.ts --version "${VERSION}" --name "${SCOOP_NAME}" env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml index 27ca8a2ebc..0aa4ff6b17 100644 --- a/.github/workflows/release-smoke-test.yml +++ b/.github/workflows/release-smoke-test.yml @@ -26,17 +26,18 @@ concurrency: group: release-smoke-test-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true -permissions: - # release-shared.yml declares privileged publish jobs. They are gated by - # dry_run here, but GitHub validates nested-workflow permissions at startup. - actions: read - contents: write - id-token: write - pull-requests: write +permissions: {} jobs: smoke: name: Run release smoke tests + # release-shared.yml declares privileged publish jobs. They are gated by + # dry_run here, but GitHub validates nested-workflow permissions at startup. + permissions: + actions: read + contents: write + id-token: write + pull-requests: write # Fork heads are skipped: the shared workflow declares write permissions a # fork pull request token cannot hold, and hotfixes come from this repository. if: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c49328b629..1a1ce8040a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,16 +267,19 @@ jobs: LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - # Republishes the supabase.com CLI reference. Stable only: the reference documents the CLI - # users actually install, so pre-releases and maintenance lines are skipped. Nothing depends on this job, so a - # docs-site failure cannot affect the already-completed release. + # Republishes the CLI reference and the config schemas to supabase.com. Stable only: both + # document the CLI users actually install, so pre-releases and maintenance lines are skipped. + # Nothing depends on this job, so a docs-site failure cannot affect the already-completed + # release; a failed publish reaches Slack through notify-slack-publish-failure. docs: - name: Publish reference docs + name: Publish to supabase.com needs: [plan, release] if: ${{ !cancelled() && needs.release.result == 'success' && needs.plan.outputs.channel == 'stable' }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 continue-on-error: true + outputs: + failed: ${{ steps.report.outputs.failed }} steps: # Scoped to supabase/supabase: this job pushes a branch and opens a PR # there, and needs nothing beyond a plain read-only checkout here. @@ -302,6 +305,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh auth setup-git - name: Publish the CLI reference + id: reference + continue-on-error: true working-directory: apps/cli # `shell: bash` for `-o pipefail`: the default `run:` shell takes only the publisher's # exit status, so a generator that died part-way would look like a successful run. @@ -317,6 +322,41 @@ jobs: fi bun scripts/generate-docs-spec.ts "$VERSION" \ | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" + - name: Publish the config schemas + id: schemas + continue-on-error: true + working-directory: apps/cli + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ needs.plan.outputs.version }} + DRY_RUN: ${{ needs.plan.outputs.dry_run }} + run: | + args=() + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + bun scripts/publish-schemas.ts --version "$VERSION" "${args[@]}" + - name: Report failed publishes + id: report + if: ${{ !cancelled() }} + env: + REFERENCE_OUTCOME: ${{ steps.reference.outcome }} + SCHEMAS_OUTCOME: ${{ steps.schemas.outcome }} + run: | + failed=false + report() { + if [[ "$2" != "success" ]]; then + failed=true + echo "::error title=Publishing the $1 failed::The $1 was not published to supabase.com (step outcome: $2)." + echo "- The $1 was not published to supabase.com (step outcome: $2)." >> "$GITHUB_STEP_SUMMARY" + fi + } + report "CLI reference" "$REFERENCE_OUTCOME" + report "config schemas" "$SCHEMAS_OUTCOME" + echo "failed=$failed" >> "$GITHUB_OUTPUT" + if [[ "$failed" == "true" ]]; then + exit 1 + fi # Notifies real (non-dry-run) stable and maintenance cuts. Nothing depends on this job, so a # Slack failure can't affect the already-completed release. @@ -337,6 +377,22 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + # Pages when the release shipped but the supabase.com publish did not. The docs job's + # continue-on-error hides its own failures, so its report step exports `failed`; a job that ran + # without exporting `failed=false` (timeout, crash before the report step) counts as failed. + notify-slack-publish-failure: + name: Notify Slack (publish failure) + needs: [plan, docs] + if: ${{ !cancelled() && needs.plan.outputs.dry_run != 'true' && needs.docs.result != 'skipped' && needs.docs.outputs.failed != 'false' }} + uses: ./.github/workflows/slack-notify.yml + with: + package: Supabase CLI + status: publish-failure + version: ${{ needs.plan.outputs.version }} + channel: ${{ needs.plan.outputs.channel }} + secrets: + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + # Reports a broken release on every channel. `failure()` evaluates against the full `needs` # chain, so this fires whenever `test-next`, `plan` or `release` (including the reusable # release-shared workflow) fails. Skipped jobs don't count as failures. Dry runs are excluded diff --git a/.github/workflows/reopen-stale-issue.yml b/.github/workflows/reopen-stale-issue.yml index dd7572b617..1235ddbefe 100644 --- a/.github/workflows/reopen-stale-issue.yml +++ b/.github/workflows/reopen-stale-issue.yml @@ -1,12 +1,17 @@ name: Reopen stale issue on: + # Anyone who can comment must be able to use /reopen. The script only reads the comment + # text and never runs it. + # jactionlint ignore=dangerous-triggers issue_comment: types: - created permissions: contents: read + # The script reopens the issue, removes a label, and comments. Each needs issues: write. + # jactionlint ignore=excessive-permissions issues: write concurrency: diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index dc9aeb1ba9..5e00dad37b 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -9,13 +9,13 @@ on: workflow_call: inputs: version: - description: Supabase CLI version to install via setup-cli (must already be published to GitHub Releases) + description: Supabase CLI version to install via setup-cli (must already be published to GitHub Releases and npm) required: true type: string workflow_dispatch: inputs: version: - description: Supabase CLI version to install via setup-cli (must already be published to GitHub Releases) + description: Supabase CLI version to install via setup-cli (must already be published to GitHub Releases and npm) required: true type: string @@ -33,10 +33,11 @@ jobs: - macos-latest - windows-latest # GitHub Actions doesn't allow expressions in `uses:`, so the action ref is selected via - # two `if:`-gated install steps below instead of interpolating `matrix.major-version`. + # `if:`-gated install steps below instead of interpolating `matrix.major-version`. major-version: - v1 - v2 + - v3 runs-on: ${{ matrix.runner }} timeout-minutes: 10 env: @@ -52,6 +53,11 @@ jobs: uses: supabase/setup-cli@afb1b15109756ea5cf9d8985a359d9095235ca2b # v2.1.2 with: version: ${{ inputs.version }} + - name: Install Supabase CLI via setup-cli@v3 + if: matrix.major-version == 'v3' + uses: supabase/setup-cli@45a513f8c64c0bc8e0e3dfe572b5c95be85f6359 # v3.0.1 + with: + version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version shell: bash run: | @@ -79,11 +85,15 @@ jobs: major-version: - v1 - v2 + - v3 runs-on: ubuntu-latest timeout-minutes: 10 # `node:20-alpine` ships a musl-linked Node, required for JavaScript-based actions to launch # here — the runner's mounted glibc Node won't execute inside an Alpine container. container: + # The image is pinned by tag, not by digest. It only hosts the installer smoke test, and + # nothing would update a digest here automatically. + # jactionlint ignore=unpinned-images image: node:20-alpine env: VERSION: ${{ inputs.version }} @@ -100,6 +110,11 @@ jobs: uses: supabase/setup-cli@afb1b15109756ea5cf9d8985a359d9095235ca2b # v2.1.2 with: version: ${{ inputs.version }} + - name: Install Supabase CLI via setup-cli@v3 + if: matrix.major-version == 'v3' + uses: supabase/setup-cli@45a513f8c64c0bc8e0e3dfe572b5c95be85f6359 # v3.0.1 + with: + version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version shell: bash run: | diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml index 81eb16cc2f..6bfbf2c800 100644 --- a/.github/workflows/slack-notify.yml +++ b/.github/workflows/slack-notify.yml @@ -18,7 +18,7 @@ on: type: string default: "" status: - description: Notification kind (success | failure | awaiting-approval | declined | sync-conflict) + description: Notification kind (success | failure | publish-failure | awaiting-approval | declined | sync-conflict) required: false type: string default: success @@ -105,6 +105,11 @@ jobs: DETAILS="*Version:* v${VERSION}\n${DETAILS}" fi ;; + publish-failure) + HEADER="⚠️ ${PACKAGE} v${VERSION} released, but publishing to supabase.com failed" + FALLBACK_TEXT="⚠️ ${PACKAGE} v${VERSION} was released, but publishing to supabase.com failed" + DETAILS="The CLI reference or config schemas pull request in supabase/supabase was not opened or updated. Rerun the *Publish to supabase.com* job.\n*Version:* v${VERSION}\n*Commit:* <${COMMIT_URL}|${SHORT_SHA}>\n*Workflow run:* <${RUN_URL}|view failed run>" + ;; awaiting-approval) # The approval itself happens on the workflow run page (Approve button + # the plan job's evidence summary) — an incoming webhook is one-way and diff --git a/.github/workflows/slim-release-published.yml b/.github/workflows/slim-release-published.yml index 35317c0735..d54acfb5d3 100644 --- a/.github/workflows/slim-release-published.yml +++ b/.github/workflows/slim-release-published.yml @@ -151,10 +151,9 @@ jobs: generated_files=( packages/stack/src/Artifacts.ts apps/cli/src/shared/services/Dockerfile - apps/cli-go/pkg/config/templates/Dockerfile ) if git diff --quiet -- "${generated_files[@]}"; then - echo "${branch}: catalog and Dockerfiles already match ${release}; skipping." + echo "${branch}: catalog and Dockerfile already match ${release}; skipping." continue fi @@ -164,10 +163,10 @@ jobs: if ! push_output="$(git push --force "$push_url" "HEAD:refs/heads/${branch}" 2>&1)"; then echo "$push_output" if ! grep -q "queued for merging cannot be updated" <<<"$push_output"; then - echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." + echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfile with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." exit 1 fi - manual_steps="run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." + manual_steps="run manually: ${manual_prefix} ${release}, regenerate the Dockerfile with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." queued_steps="Once ${branch} leaves the merge queue, ${manual_steps}" # The app token minted just before this step expires after one hour; capping the wait at # 50 minutes into the step leaves room for the lookups and the dispatch that follow it. diff --git a/.github/workflows/stack-stress.yml b/.github/workflows/stack-stress.yml index 2d10715a3f..e204ca5204 100644 --- a/.github/workflows/stack-stress.yml +++ b/.github/workflows/stack-stress.yml @@ -37,7 +37,6 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - go-cache: "false" - name: Setup Podman if: matrix.runtime == 'podman' diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml index 5058c163f2..5ab7797833 100644 --- a/.github/workflows/sync-branches.yml +++ b/.github/workflows/sync-branches.yml @@ -1,6 +1,9 @@ name: Sync branches on: + # Starts when the Release workflow finishes. Every script runs from the default-branch + # checkout, never from the merged tree. + # jactionlint ignore=dangerous-triggers workflow_run: workflows: - Release @@ -120,7 +123,8 @@ jobs: persist-credentials: false - name: Load Claude model - run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + id: model + run: echo "model=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -134,6 +138,7 @@ jobs: bun-version-file: trusted/.bun-version # Same pinned release and isolated npm config as the AI review workflow. + # jactionlint ignore=adhoc-packages - name: Install Claude Code CLI run: | npm install -g \ @@ -146,6 +151,7 @@ jobs: working-directory: trusted env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_MODEL: ${{ steps.model.outputs.model }} WORK_DIR: ${{ github.workspace }}/work run: | git -C "$WORK_DIR" config user.name "github-actions[bot]" @@ -198,9 +204,9 @@ jobs: bun-version-file: trusted/.bun-version # Installs mise only; the script installs the merged tree's own toolchain. - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5.2.2 with: - version: 2026.9.0 + version: 2026.10.17 install: false working_directory: work @@ -246,7 +252,8 @@ jobs: persist-credentials: false - name: Load Claude model - run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + id: model + run: echo "model=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -260,6 +267,7 @@ jobs: bun-version-file: trusted/.bun-version # Same pinned release and isolated npm config as the AI review workflow. + # jactionlint ignore=adhoc-packages - name: Install Claude Code CLI run: | npm install -g \ @@ -276,6 +284,7 @@ jobs: working-directory: trusted env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_MODEL: ${{ steps.model.outputs.model }} WORK_DIR: ${{ github.workspace }}/work run: | git -C "$WORK_DIR" fetch --no-tags "$OUTPUT_DIR/sync.bundle" HEAD diff --git a/.github/workflows/sync-repair.yml b/.github/workflows/sync-repair.yml index a9002a4085..d86840984b 100644 --- a/.github/workflows/sync-repair.yml +++ b/.github/workflows/sync-repair.yml @@ -3,9 +3,11 @@ name: Sync repair # Once CI and the AI review finish on a Claude-resolved sync/develop-into-next pull request, lets Claude fix what # the merge broke and answer the review. See apps/cli/docs/release-process.md#claude-resolved-develop-into-next. on: + # Starts when the sync branch's checks finish. Every script runs from the default-branch + # checkout, never from the sync branch. + # jactionlint ignore=dangerous-triggers workflow_run: workflows: - - CI - Test - AI Review - GitHub Scripts CI @@ -55,7 +57,8 @@ jobs: persist-credentials: false - name: Load Claude model - run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + id: model + run: echo "model=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_OUTPUT" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -77,6 +80,7 @@ jobs: persist-credentials: false # Same pinned release and isolated npm config as the AI review workflow. + # jactionlint ignore=adhoc-packages - name: Install Claude Code CLI if: steps.collect.outputs.action == 'repair' run: | @@ -91,6 +95,7 @@ jobs: working-directory: trusted env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_MODEL: ${{ steps.model.outputs.model }} WORK_DIR: ${{ github.workspace }}/work HEAD_SHA: ${{ steps.collect.outputs.head }} run: | @@ -119,6 +124,7 @@ jobs: HEAD_SHA: ${{ steps.collect.outputs.head }} CHANGED: ${{ steps.repair.outputs.changed }} run: | + set -o pipefail if [ "$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json headRefOid -q .headRefOid)" != "$HEAD_SHA" ]; then echo "::notice::The sync branch moved since the checks settled; skipping." exit 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2fe2ca454..fbf89feabb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,9 +73,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - - name: Unlock keyring (for cli-go keyring tests) - uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 - - name: Check code quality run: pnpm run check:all @@ -110,7 +107,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - go-cache: "false" - name: Run unit tests run: pnpm run test:unit @@ -145,7 +141,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - go-cache: "false" docker-images: "true" - name: Run integration tests @@ -242,7 +237,6 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - go-cache: "false" tools: node bun pnpm # Windows unpacks the store archive slower than it downloads the packages. pnpm-store-cache: ${{ matrix.os != 'windows-latest' }} @@ -316,7 +310,6 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - go-cache: "false" # Keyed per suite so each job saves the artifacts it actually prepared. - name: Cache native stack artifacts @@ -407,14 +400,11 @@ jobs: if: matrix.suite == 'cli' run: pnpm exec turbo run test:e2e:run --only --filter=supabase -- --shard=${{ matrix.shard }}/${{ matrix.shards }} env: - SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go SUPABASE_E2E_WORKERS: "4" - name: Run cli-e2e end-to-end tests if: matrix.suite == 'cli-e2e' run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/${{ matrix.shards }} - env: - SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go # Vitest writes its results cache under a sha1-named directory. The refresh script # (apps/cli/scripts/refresh-e2e-timings.ts) merges these into tests/e2e-timings.json. @@ -459,7 +449,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - go-cache: "false" docker-images: ${{ matrix.runtime == 'docker' }} native-artifacts: ${{ matrix.runtime == 'native' }} @@ -507,7 +496,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} sticky-disks: "true" - go-cache: "false" - name: Setup Podman uses: ./.github/actions/setup-podman diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index 26bfa0d90b..82cba9a036 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -6,9 +6,7 @@ name: Verify Install Channels # an already-published version. # # Performs a real install so a manifest/tarball checksum mismatch (as in CLI v2.107.0) reproduces -# here instead of being trusted from the publish step. Each leg also directly checks that the -# `supabase-go` sidecar binary is present and executable in the install directory, checking the -# file rather than a CLI command, since a command could later be natively ported off the sidecar. +# here instead of being trusted from the publish step. on: workflow_call: @@ -137,23 +135,8 @@ jobs: echo "Version mismatch: expected ${expected}, got ${actual}" >&2 exit 1 fi - - name: Verify Go sidecar - run: | - set -euo pipefail - # Homebrew's `bin.install` symlinks both `supabase` and `supabase-go` - # into the same prefix bin/ directory, so the sidecar must sit right - # next to whichever `supabase` resolved from PATH. - bin_dir="$(dirname "$(command -v supabase)")" - sidecar="${bin_dir}/supabase-go" - if [ ! -e "${sidecar}" ]; then - echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 - exit 1 - fi - if [ ! -x "${sidecar}" ]; then - echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 - exit 1 - fi - echo "Go sidecar probe OK: ${sidecar}" + - name: Exercise supabase --help + run: supabase --help scoop: name: Scoop (${{ inputs.scoop_name }}) @@ -191,23 +174,9 @@ jobs: echo "Version mismatch: expected ${expected}, got ${actual}" >&2 exit 1 fi - - name: Verify Go sidecar + - name: Exercise supabase --help shell: bash - run: | - set -euo pipefail - # Scoop's manifest only declares `supabase.exe` in `bin` (see - # apps/cli/scripts/update-scoop.ts), so only `supabase` gets a shim in - # ~/scoop/shims — `dirname "$(command -v supabase)"` would resolve to - # the shim directory, not the real install directory supabase-go.exe - # actually lives in. Go straight to the app's current version - # directory instead, which Scoop always maintains regardless of shims. - app_dir="${HOME}/scoop/apps/${SCOOP_NAME}/current" - sidecar="${app_dir}/supabase-go.exe" - if [ ! -e "${sidecar}" ]; then - echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 - exit 1 - fi - echo "Go sidecar probe OK: ${sidecar}" + run: supabase --help install-script: name: install script (${{ matrix.runner }}) @@ -222,6 +191,9 @@ jobs: env: VERSION: ${{ inputs.version }} steps: + # This job exists to run the published install script. That script checks the tarball it + # downloads against the release's checksums.txt before using it. + # jactionlint ignore=unverified-download - name: Install via the published install script shell: bash run: | @@ -248,21 +220,6 @@ jobs: echo "Version mismatch: expected ${expected}, got ${actual}" >&2 exit 1 fi - - name: Verify Go sidecar + - name: Exercise supabase --help shell: bash - run: | - set -euo pipefail - # The install script places `supabase-go` right next to `supabase`, - # so the sidecar must sit in the same directory `supabase` resolved - # from on PATH. - bin_dir="$(dirname "$(command -v supabase)")" - sidecar="${bin_dir}/supabase-go" - if [ ! -e "${sidecar}" ]; then - echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 - exit 1 - fi - if [ ! -x "${sidecar}" ]; then - echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 - exit 1 - fi - echo "Go sidecar probe OK: ${sidecar}" + run: supabase --help diff --git a/.oxfmtrc.json b/.oxfmtrc.json index e364381a7e..33527f22c6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,7 +3,6 @@ ".repos/", "apps/cli/src/shared/feedback/database.types.ts", "packages/stack/src/functions/generated/", - "apps/cli-go/", "apps/cli-e2e/fixtures/", "apps/docs/content/docs/commands/", "apps/docs/public/", diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index edf2b3ef1c..d3209edd72 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -6,136 +6,20 @@ }, "ignorePatterns": [ "**", + "!packages/api/**", "!packages/config/**", "!packages/stack/**", - "!apps/cli/src/auth/**", - "!apps/cli/src/cli/**", - "!apps/cli/src/commands/**", - "!apps/cli/src/config/**", - "!apps/cli/src/docs/**", - "!apps/cli/src/globals.d.ts", - "!apps/cli/src/main.ts", - "!apps/cli/src/output/**", - "!apps/cli/src/shared/**", - "!apps/cli/src/telemetry/**", + "!apps/cli-e2e/**", + "!apps/cli/**", + "!apps/docs/**", + "!packages/cli-test-helpers/**", + "!tools/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", "apps/cli/src/shared/compute/stacks/**", "apps/cli/src/shared/feedback/database.types.ts", - "!apps/cli/tests/helpers/branches-live.ts", - "!apps/cli/tests/helpers/compute.ts", - "!apps/cli/tests/helpers/migration-live.ts", - "!apps/cli/tests/helpers/notebooks.ts", - "!apps/cli/tests/helpers/postgres-config-live.ts", - "!apps/cli/tests/helpers/secrets-live.ts", - "!apps/cli/tests/helpers/storage-live.ts", - "!apps/cli/src/command-internal/agent-mode.*", - "!apps/cli/src/command-internal/api-*", - "!apps/cli/src/command-internal/bitbucket-pipeline.ts", - "!apps/cli/src/command-internal/branch-ref.*", - "!apps/cli/src/command-internal/branch-target.*", - "!apps/cli/src/command-internal/bundled-postgres-client.*", - "!apps/cli/src/command-internal/colors.*", - "!apps/cli/src/command-internal/config-pull-run.*", - "!apps/cli/src/command-internal/config-validate.*", - "!apps/cli/src/command-internal/connect-errors.*", - "!apps/cli/src/command-internal/container-cli.*", - "!apps/cli/src/command-internal/db-*.ts", - "!apps/cli/src/command-internal/db-bootstrap/**", - "!apps/cli/src/command-internal/debug-logger.*", - "!apps/cli/src/command-internal/detect-content-type.*", - "!apps/cli/src/command-internal/diff-engine.*", - "!apps/cli/src/command-internal/docker-*.ts", - "!apps/cli/src/command-internal/dotenv.*", - "!apps/cli/src/command-internal/drop-objects.ts", - "!apps/cli/src/command-internal/edge-runtime-*.ts", - "!apps/cli/src/command-internal/ensure-login.*", - "!apps/cli/src/command-internal/error-message.ts", - "!apps/cli/src/command-internal/experimental-feature.ts", - "!apps/cli/src/command-internal/experimental-gate.*", - "!apps/cli/src/command-internal/fail-on.*", - "!apps/cli/src/command-internal/get-api-keys.*", - "!apps/cli/src/command-internal/get-tenant-api-keys.*", - "!apps/cli/src/command-internal/git-status.*", - "!apps/cli/src/command-internal/glob.*", - "!apps/cli/src/command-internal/global-flags.*", - "!apps/cli/src/command-internal/go-duration.*", - "!apps/cli/src/command-internal/go-float.*", - "!apps/cli/src/command-internal/go-json.*", - "!apps/cli/src/command-internal/go-jwt.*", - "!apps/cli/src/command-internal/go-output-flag.*", - "!apps/cli/src/command-internal/go-output.encoders.*", - "!apps/cli/src/command-internal/go-quote.ts", - "!apps/cli/src/command-internal/go-struct-output.*", - "!apps/cli/src/command-internal/hostname.*", - "!apps/cli/src/command-internal/http-*", - "!apps/cli/src/command-internal/identity-stitch.*", - "!apps/cli/src/command-internal/kong-*", - "!apps/cli/src/command-internal/link-services-core.*", - "!apps/cli/src/command-internal/linked-state.*", - "!apps/cli/src/command-internal/local-project-context.*", - "!apps/cli/src/command-internal/login-*", - "!apps/cli/src/command-internal/make-dir.*", - "!apps/cli/src/command-internal/management-api-runtime.*", - "!apps/cli/src/command-internal/migrate-and-seed.*", - "!apps/cli/src/command-internal/migration*", - "!apps/cli/src/command-internal/param-introspection.*", - "!apps/cli/src/command-internal/parent-project-ref.*", - "!apps/cli/src/command-internal/parse-uint.*", - "!apps/cli/src/command-internal/password-requirements.ts", - "!apps/cli/src/command-internal/path-match.*", - "!apps/cli/src/command-internal/pflag-reconcile.*", - "!apps/cli/src/command-internal/pg-dump.*", - "!apps/cli/src/command-internal/pg-net-guidance.*", - "!apps/cli/src/command-internal/pgdelta*", - "!apps/cli/src/command-internal/pgpass.*", - "!apps/cli/src/command-internal/pgservicefile.*", - "!apps/cli/src/command-internal/pooler-fallback.*", - "!apps/cli/src/command-internal/postgres-client.run.*", - "!apps/cli/src/command-internal/postgres-url.*", - "!apps/cli/src/command-internal/profile-load.*", - "!apps/cli/src/command-internal/profile.*", - "!apps/cli/src/command-internal/project-create-core.*", - "!apps/cli/src/command-internal/project-environment.*", - "!apps/cli/src/command-internal/project-target.*", - "!apps/cli/src/command-internal/prompt-yes-no.*", - "!apps/cli/src/command-internal/raw-http.*", - "!apps/cli/src/command-internal/ref-patterns.*", - "!apps/cli/src/command-internal/region.format.*", - "!apps/cli/src/command-internal/resolve-token.*", - "!apps/cli/src/command-internal/rune-width.*", - "!apps/cli/src/command-internal/schema-flags.*", - "!apps/cli/src/command-internal/seed*", - "!apps/cli/src/command-internal/service-catalog.*", - "!apps/cli/src/command-internal/service-version-overrides.*", - "!apps/cli/src/command-internal/shell-quote.ts", - "!apps/cli/src/command-internal/size-units.ts", - "!apps/cli/src/command-internal/sql-files-glob.*", - "!apps/cli/src/command-internal/sql-split.*", - "!apps/cli/src/command-internal/stack-*", - "!apps/cli/src/command-internal/start-*", - "!apps/cli/src/command-internal/status-errors.ts", - "!apps/cli/src/command-internal/status-pretty.*", - "!apps/cli/src/command-internal/status-values.*", - "!apps/cli/src/command-internal/storage-*.ts", - "!apps/cli/src/command-internal/string-slice-flag.*", - "!apps/cli/src/command-internal/temp-paths.*", - "!apps/cli/src/command-internal/tenant-*", - "!apps/cli/src/command-internal/test-db.*", - "!apps/cli/src/command-internal/timestamp.format.*", - "!apps/cli/src/command-internal/upgrade-notice.*", - "!apps/cli/src/command-internal/upgrade-suggest.*", - "!apps/cli/src/command-internal/vault-decrypt.*", - "!apps/cli/src/command-internal/vault.*", - "!apps/cli/src/command-internal/viper-env.*", - "!apps/cli/src/command-internal/workdir-project.*", - "!apps/cli/src/command-internal/workdir-search.ts", - "!apps/cli/src/command-internal/workdir-validation.ts", - "apps/cli/src/command-internal/config-validate.parity.unit.test.ts", - "apps/cli/src/command-internal/config-validate.ts", - "apps/cli/src/command-internal/config-validate.unit.test.ts", - "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", - "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts" + "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts", + "packages/api/src/generated/contracts.ts" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 9df557b0bb..51bff7da6a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,7 +6,6 @@ }, "ignorePatterns": [ ".repos", - "apps/cli-go", // Generated by `supabase gen types` (see `gen:feedback-types` in apps/cli). "apps/cli/src/shared/feedback/database.types.ts", "apps/cli-e2e/fixtures", @@ -45,6 +44,40 @@ "supabase/no-raw-management-api-url": "error" } }, + { + // Ambient env reads go through `CliConfigValues` (registry names) or the shared env-option + // helpers, so a stray `process.env` can't bypass the flag > shell > .env > config order. + "files": ["apps/cli/src/**"], + "excludeFiles": [ + "apps/cli/src/**/*.test.ts", + "apps/cli/src/shared/config/cli-config-provider.layer.ts", + "apps/cli/src/shared/config/cli-config-env.ts", + // Standalone node shim built with `bun build --target node`; it cannot import effect. + "apps/cli/src/shared/cli/bin.ts", + // Templates that run inside the user's deployed container, not in the CLI process. + "apps/cli/src/shared/compute/stacks/**" + ], + "rules": { + "no-restricted-properties": [ + "error", + { + "object": "process", + "property": "env", + "message": "Read env through CliConfigValues or the shared env helpers, not process.env." + }, + { + "object": "Bun", + "property": "env", + "message": "Read env through CliConfigValues or the shared env helpers, not Bun.env." + }, + { + "object": "globalThis", + "property": "process", + "message": "Use the bare `process` global; reading env through globalThis.process bypasses CliConfigValues." + } + ] + } + }, { // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers` // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at @@ -76,6 +109,108 @@ ] } }, + { + // Env must not be imported past `CliConfigValues`: not `env` from node:process/bun, and + // `ambientEnvironment` only in the audited files of the next override. This rule replaces the + // repo-wide one for these files, so the namespace pattern is repeated; keep the two in sync. + "files": ["apps/cli/src/**"], + "excludeFiles": [ + "apps/cli/src/**/*.test.ts", + "apps/cli/src/shared/config/cli-config-provider.layer.ts", + "apps/cli/src/shared/config/cli-config-env.ts", + "apps/cli/src/shared/cli/bin.ts", + "apps/cli/src/shared/compute/stacks/**", + "apps/cli/src/cli/complete.ts", + "apps/cli/src/command-internal/colors.ts", + "apps/cli/src/command-internal/db-config.parse.ts", + "apps/cli/src/command-internal/hostname.ts", + "apps/cli/src/command-internal/pgpass.ts", + "apps/cli/src/commands/login/login-claude-hint.ts", + "apps/cli/src/commands/start/lib/env-or-default.ts", + "apps/cli/src/commands/start/services/vector.service.ts", + "apps/cli/src/shared/functions/deploy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not node:process env." + }, + { + "name": "process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not process env." + }, + { + "name": "bun", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not Bun's env export." + } + ], + "patterns": [ + { + "group": ["**/cli-config-provider.layer.ts"], + "importNames": ["ambientEnvironment"], + "message": "Read env through CliConfigValues; only the audited non-registry readers may import ambientEnvironment." + }, + { + "group": ["**/namespace/**"], + "message": "Namespace internals are private; consume them through StackNamespace.ts." + } + ] + } + ] + } + }, + { + // These read terminal, libpq, docker and proxy variables, never a config registry name, so + // they may import `ambientEnvironment`; the rest of the override above still applies. + "files": [ + "apps/cli/src/cli/complete.ts", + "apps/cli/src/command-internal/colors.ts", + "apps/cli/src/command-internal/db-config.parse.ts", + "apps/cli/src/command-internal/hostname.ts", + "apps/cli/src/command-internal/pgpass.ts", + "apps/cli/src/commands/login/login-claude-hint.ts", + "apps/cli/src/commands/start/lib/env-or-default.ts", + "apps/cli/src/commands/start/services/vector.service.ts", + "apps/cli/src/shared/functions/deploy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not node:process env." + }, + { + "name": "process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not process env." + }, + { + "name": "bun", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not Bun's env export." + } + ], + "patterns": [ + { + "group": ["**/namespace/**"], + "message": "Namespace internals are private; consume them through StackNamespace.ts." + } + ] + } + ] + } + }, { // Default for the whole stack package (`src/**` and `tests/**`): on top of the repo-wide // namespace restriction above, raw `node:fs`/`node:sqlite` are confined to namespace drivers diff --git a/AGENTS.md b/AGENTS.md index b6b56afec5..3afcece062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ page, or upstream issue. If the rationale needs more than three lines, move it t ### Never write - Code narration, provenance/history, evidence trails, ticket IDs as provenance, or meta-commentary on code shape. A ticket ID belongs only in a `TODO(CLI-1234):` or when no ADR exists and the ticket is the only home for a decision. -- Restatements of docs, section banners, or Go-parity framing. Link to maintained docs instead. +- Restatements of docs, or section banners. Link to maintained docs instead. - ALL-CAPS emphasis or words such as “deliberately”, “crucially”, and “exactly”. Tests should carry intent in their names; comments only explain non-obvious fixture setup. Keep diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20b02d8a8a..ae3a96014d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Pull requests from external contributors that do not follow this workflow are co ### Tool versions -This repo pins the versions of Node, Bun, Go, pnpm, and golangci-lint that contributors are expected to build against, and uses [`mise`](https://mise.jdx.dev/) — a polyglot version manager — to install and activate them automatically. If you don't already have these tools installed, `mise` is a great way to get up and running quickly. +This repo pins the versions of Node, Bun, and pnpm that contributors are expected to build against, and uses [`mise`](https://mise.jdx.dev/) — a polyglot version manager — to install and activate them automatically. If you don't already have these tools installed, `mise` is a great way to get up and running quickly. #### Installing mise @@ -52,23 +52,19 @@ mise install `mise install` resolves the versions this repo expects from a handful of files, rather than hardcoding them all in one place: -| Tool | Version source | -| ------------- | --------------------------------------------------- | -| Bun | `.bun-version` | -| Node.js | `.node-version` | -| pnpm | `devEngines.packageManager` field in `package.json` | -| Go | `mise.toml` | -| golangci-lint | `mise.toml` | -| jactionlint | `mise.toml` | -| shellcheck | `mise.toml` | - -The Go and golangci-lint entries in `mise.toml` are intentionally temporary while the Go CLI remains in the repo. The canonical Go module metadata still lives in `apps/cli-go/go.mod`; keep the `mise.toml` entries aligned only until the Go code is removed. +| Tool | Version source | +| ----------- | --------------------------------------------------- | +| Bun | `.bun-version` | +| Node.js | `.node-version` | +| pnpm | `devEngines.packageManager` field in `package.json` | +| jactionlint | `mise.toml` | +| shellcheck | `mise.toml` | Once installed, `mise` activates these versions automatically whenever your shell is inside this repo — no manual `nvm use`, `gvm use`, or similar switching required. #### Without mise -`mise` is not required. If you already have Bun, Node, pnpm, Go, jactionlint, and shellcheck installed and managed some other way, just make sure your versions match the ones pinned in `.bun-version`, `.node-version`, `mise.toml`, `package.json`, and `apps/cli-go/go.mod`. `pnpm check:all` runs jactionlint, which only checks workflow `run:` scripts when shellcheck is on `PATH`. +`mise` is not required. If you already have Bun, Node, pnpm, jactionlint, and shellcheck installed and managed some other way, just make sure your versions match the ones pinned in `.bun-version`, `.node-version`, `mise.toml`, and `package.json`. `pnpm check:all` runs jactionlint, which only checks workflow `run:` scripts when shellcheck is on `PATH`. ### Install dependencies @@ -180,16 +176,14 @@ pnpm run test:unit && pnpm run test:integration ``` The root unit and integration scripts use Turbo to fan out the package-local -`test:*:run` tasks across the standard TypeScript/Vitest workspaces. The Go -workspace remains package-local because its tests run directly through Go: -`pnpm --dir apps/cli-go run test:unit`. Go tests are covered by the dedicated -Go CI workflow. Unit and integration tasks are uncached for now; e2e tasks are -also uncached and run one package at a time. Forward a Vitest shard to every -e2e package with `pnpm run test:e2e --shard=1/3`. +`test:*:run` tasks across the standard TypeScript/Vitest workspaces. Unit and +integration tasks are uncached for now; e2e tasks are also uncached and run +one package at a time. Forward a Vitest shard to every e2e package with +`pnpm run test:e2e --shard=1/3`. ## E2E Compatibility Test Suite -`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript CLI. Live tests are owned by `apps/cli` and run from the command they cover. The CLI still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff --use-pg-schema`, `db branch *`, `db remote changes`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for these suites, but there is no Go-vs-TypeScript parity runner. +`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript CLI. Live tests are owned by `apps/cli` and run from the command they cover. ### Architecture @@ -286,7 +280,6 @@ Test a real end-to-end publish and install of the CLI against a local npm regist ### Prerequisites - **Bun** — for compiling the CLI binary and running the scripts -- **Go** — required to build the `supabase-go` sidecar (commands proxied to the Go binary) - **pnpm** — already required by this repo - **Node.js** — required by `npx` / `npm install -g` to test the published package @@ -333,8 +326,6 @@ supabase --version | Problem | Fix | | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Error: Something is already running on port 4873` | Kill the leftover Verdaccio process (`lsof -ti:4873 \| xargs kill`) and retry | -| `go not found in PATH` | Install Go from https://go.dev/dl/ | -| `Error: Go CLI source not found` | Run `pnpm repos:install` to clone `apps/cli-go` | | `npm` / `pnpm` tries to fetch from `localhost:4873` when no registry is running | Stale global registry override left behind by an older version of `local-registry.ts` (the current script never modifies global config). Run `npm config delete registry` and `pnpm config delete registry`. Note that pnpm stores the override in its own global config (`~/Library/Preferences/pnpm/auth.ini` on macOS, `~/.config/pnpm/` on Linux), not `~/.npmrc` — check there if the delete command fails | | `npx` resolves from npm instead of local | Pass `--registry http://localhost:4873` explicitly to `npx` / `npm install` | @@ -363,15 +354,15 @@ pnpm run build pnpm run generate ``` -**Build only the CLI and its Go sidecar:** +**Build only the CLI:** ```sh pnpm exec turbo run supabase#build ``` -The CLI build names its config and Go build prerequisites explicitly. The -remaining CLI workspace dependencies intentionally have no build script, so -strict Turbo task selection does not synthesize no-op `^build` tasks for them. +The CLI build names its config build prerequisite explicitly. The remaining +CLI workspace dependencies intentionally have no build script, so strict +Turbo task selection does not synthesize no-op `^build` tasks for them. **Run the live suite:** diff --git a/apps/cli-go/LICENSE b/LICENSE similarity index 100% rename from apps/cli-go/LICENSE rename to LICENSE diff --git a/README.md b/README.md index cebc9eaab6..f9515f796d 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ supabase link ## Manage Your Database -Create migrations, compare schemas, and apply changes locally or to your linked project: +Create migrations, compare schemas, and apply changes to your local database. Add `--remote` to act +on your linked project instead: ```sh supabase migration new create_profiles supabase db diff supabase db push +supabase db push --remote supabase db reset ``` @@ -101,8 +103,8 @@ supabase functions deploy hello-world Generate TypeScript types from your local database or linked project: ```sh -supabase gen types --local -supabase gen types --linked +supabase gen types +supabase gen types --remote ``` ## Reference @@ -137,7 +139,6 @@ Useful source entry points: | Path | Purpose | | ----------------- | ------------------------------------ | | `apps/cli` | TypeScript/Bun CLI package | -| `apps/cli-go` | Go CLI source used by the CLI | | `packages/stack` | Local Supabase stack runtime | | `packages/config` | Config schema and generated types | | `packages/api` | Typed Supabase Management API client | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..01b10a04e0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,232 @@ +# Security Policy + +Thank you for helping keep the Supabase CLI and its users safe. + +## Reporting a vulnerability + +Please report suspected vulnerabilities through our HackerOne program: +. Please do not open public GitHub issues or +pull requests for security problems. + +This repository contains the Supabase CLI. It runs on a developer's machine or +in CI, starts the local development stack (`supabase start`), manages +migrations, deploys and downloads Edge Functions, and talks to the hosted +Supabase Management API on the user's behalf. There is no hosted CLI: the +target is the CLI and the local stack it starts on your own machine. Issues in +the hosted Management API itself are not tracked in this repository. Report +them through HackerOne as usual and say which API you tested. + +## What we consider a vulnerability + +In general, we want to hear about anything that lets an attacker get a CLI +user's credentials, act as that user, or run code or write files on their +machine without the user choosing to: obtaining a Supabase access token or +project secret the attacker should not have, redirecting the CLI's +credentials to a server the user did not choose, connecting to a database or +API without properly verifying who is on the other end, or turning data that +the CLI downloads into a write outside the location it was meant for. The +attacker should not need the victim's credentials or a shell on the victim's +machine, and the problem should be present in a released version of the CLI +used as documented. + +Examples of what this includes (these are illustrations, not a complete list): + +- **Theft of a CLI access token.** Getting a user to complete a CLI login, or + any other flow, in a way that hands the resulting access token to the + attacker. +- **Writes outside the intended directory.** Content the CLI downloads (for + example a function bundle or its file paths) that makes the CLI create or + overwrite files outside the project directory it was asked to write to. Show + the file actually written to a location of the attacker's choosing. +- **Secret exposure.** An access token, database password or project secret + key written to output, logs or local files in a place where the CLI is meant + to redact or omit it. +- **Credentials sent to the wrong place.** Project or repository content that + the CLI reads, such as a `.env` file or `config.toml`, that changes where the + CLI sends its access token or which server it talks to, without the user + choosing that. +- **Server identity not verified.** A CLI connection that carries credentials or + data, such as to the project database or the Management API, that does not + verify the server's TLS certificate and hostname, or that silently accepts a + weaker mode than the user asked for. This is in scope even though exploiting + it needs a network position between the user and the server: the CLI is + expected to protect those connections, so it is not the MITM exclusion + below. Show which command connects, what the attacker intercepts, and the + CLI version. + +## What we do not consider a vulnerability + +Reports that rely on the behavior below will normally be closed as +informative or not applicable. + +**By design** + +- **The local development stack is for your own workstation.** The stack that + `supabase start` runs is meant for local development. It has no + authorization or isolation layer suitable for the internet, and our + documentation says it should not be exposed publicly. Reports against a + local stack that someone has made reachable from the internet are reports + about that deployment, not about the CLI. If you believe the same issue + affects a self-hosted deployment, reproduce it using the self-hosting guide + and say so. +- **The CLI uses credentials that are already present.** If an access token is + set in the environment (`SUPABASE_ACCESS_TOKEN`) or saved by an earlier + `supabase login`, commands that call the Management API will use it without + prompting. A command that "works without logging in" on a machine or agent + session that already holds a token is not an authentication bypass. Retest in + a clean environment. +- **The CLI acts with its user's privileges.** Running SQL, pushing + migrations, deploying functions or managing secrets for projects the token + can reach is the purpose of the tool. + +**Prerequisites that make the attack pointless** + +- The attacker needs the victim's access token, database password or project + secret key. +- The attacker already has a shell, or write access to the user's home + directory, on the victim's machine. +- The attacker needs owner or admin rights on the victim's project or + organization, and the action is one those rights already allow. + +**Reports that are not evidence of a flaw** + +- **Credentials in the git history of this repository** that are revoked or no + longer valid, including values in old or commented-out config templates. + Show that a credential is live and grants access. +- **Static analysis or scanner output** with no working proof of concept + against a released CLI version. + +## Out of scope for every Supabase asset + +These are excluded program-wide (see also +). They are repeated here so +you do not need to leave this repository to find them. + +- Clickjacking on pages with no sensitive actions. +- Unauthenticated, logout and login CSRF. +- Attacks requiring MITM or physical access to a user's device. +- Attacks requiring social engineering. +- Any activity that could lead to the disruption of our service (DoS). + This means volumetric attacks, load testing and flooding. A vulnerability in + our code that lets an unprivileged attacker crash a service or exhaust + resources for other users or tenants is in scope. Denial of service that only + affects the attacker's own project, or that needs admin or owner privileges, + is not. +- Content spoofing and text injection issues without showing an attack vector, + or without being able to modify HTML/CSS. +- Email spoofing. +- Missing DNSSEC, CAA or CSP headers. +- Lack of Secure or HttpOnly flags on non-sensitive cookies. +- Dead links. +- User enumeration. + +## Known and documented behavior + +These are described in our public documentation. Please check them before +reporting. + +- **Local development is not self-hosting.** The local development guide and + the self-hosting guide describe different setups. The local stack is not + intended to be exposed outside the developer's machine. +- **`SUPABASE_ACCESS_TOKEN`** can be set in the environment to authenticate the + CLI without an interactive login, for example in CI. + +## Local development stack vs. custom setups + +We accept findings against released CLI versions used as documented. We treat +findings that need the user to misconfigure their environment first, such as by +exposing the local stack to a network, changing its `config.toml` defaults, or +running a modified CLI, as lower severity. Label your report as described in +the next section. + +## How to write a report that gets triaged quickly + +- **Say what you tested, at the top of the report and in the title.** Use one of + these labels, for example `[Local CLI] ...` in the title and a "Target" line + at the start of the description: + - **Local CLI, default configuration**: a released CLI version, and the local + stack it starts, used exactly as our documentation describes with the + default `config.toml`. Include the CLI version, how you installed it, and + your operating system. + - **Local CLI, custom configuration**: you changed `config.toml`, exposed the + local stack beyond your machine, built from a branch, or modified code. List + every change and why. We triage these lower unless the setting is one we + recommend or document. + + Reports that do not say which of the two they are about are slower to + triage, and are often closed with a request to retest. A finding that works + only on a custom configuration is not a finding against a default install. + +- **Test a real setup.** Run the released CLI binary, not only code you copied + out of it. Code reading alone is rarely enough. Static analysis findings need + a working proof of concept, and the code you cite must match the current + repository. +- **Give a short, runnable PoC.** The exact commands, any files or server + responses the attacker controls, and the output showing the impact, such as + the file written or the token received. +- **State the attacker's starting position.** What do they control (a function + in an organization the victim belongs to, a web page, a leaked key)? What + does the victim have to run? Reports that quietly assume a token already in + the environment, or a shell on the victim's machine, are our most common + reason to close. +- **Name the boundary you crossed.** Which user, project or organization could + not do this before, or which location could the attacker not write to? If + nothing was crossed, it is probably a hardening suggestion. +- **Check the docs and this file first.** If the behavior is documented, explain + why the documented design still fails. +- **Include versions.** The CLI version, installation method, operating system, + and whether any `SUPABASE_*` environment variables were set. +- **One issue per report.** Link variants of the same root cause instead of + filing each variant separately. +- **Do not send unreviewed AI output.** Reports that were not reproduced by a + human and that ignore the documentation are closed. AI-assisted analysis is + fine when you have verified the result. If an AI agent ran the CLI for you, + say whether it had logged in or had a token. +- **Respond to follow-up questions.** Reports that go quiet after we ask for a + reproduction are closed. + +## Severity + +We rate severity by realistic impact on a default setup. Issues that need user +interaction the user can see, a deliberately exposed local stack, or an +existing privilege are rated lower even when they are valid, and we may fix +them as defense in depth. + +## Testing and disclosure + +These are our program-wide rules. They also apply to AI agents and automated +tools acting on your behalf. + +**Testing** + +- Do not run automated scanners against other customers' projects. Scanners can + run up costs for our users, can disrupt services, and our own security + systems cannot tell hostile reconnaissance from research. If you want to run + an automated scanner, notify security@supabase.com first and run it only on + your own Supabase project. +- Do not attack projects that belong to other customers. +- Do not take advantage of a vulnerability you find, for example by downloading + more data than necessary to demonstrate it, or by deleting or modifying other + people's data. + +**Disclosure** + +- Do not reveal the problem to others until we have researched and fixed it and + informed affected customers. +- If you want to share your research publicly (a conference, a blog or any + other public forum), send us a draft for review and approval at least 30 days + before the publication date. Do not include data about Supabase customer + projects, customers' data, or information about Supabase employees, + contractors or partners. + +**What we promise** + +- We will respond to your report within 5 business days with our evaluation and + an expected resolution date. +- If you have followed these instructions, we will not take legal action against + you in regard to the report. +- We will handle your report with strict confidentiality and will not pass on + your personal details to third parties without your permission. +- We will keep you informed of our progress. +- In public information about the problem, we will name you as the discoverer + unless you prefer otherwise. diff --git a/apps/cli-e2e/.env.example b/apps/cli-e2e/.env.example index d437835f33..fb502b3f75 100644 --- a/apps/cli-e2e/.env.example +++ b/apps/cli-e2e/.env.example @@ -7,12 +7,6 @@ CLI_E2E_MODE=record # Staging Management API token. Required in record mode. SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# The CLI shells out to the bundled Go binary for the proxied commands -# (db diff --use-pg-schema, db branch *, db remote changes, gen keys, -# functions download --legacy-bundle). Point at a freshly built binary: -# cd apps/cli-go && go build -o /tmp/supabase-test-binary . -SUPABASE_GO_BINARY=/tmp/supabase-test-binary - # --- Optional record overrides (sensible defaults in src/tests/env.ts) --- # Management API base (also accepted as SUPABASE_STAGING_URL). # CLI_E2E_API_URL=https://api.supabase.green diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 41f46855f0..0b75112979 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -61,20 +61,30 @@ the running tests exercise. The implications: ### `testBehaviour` — the default for all success and error tests ```typescript -import { testBehaviour } from "./test-context.ts"; - -testBehaviour("does the thing", async ({ run, projectRef, orgId, apiUrl, workspace }) => { - const result = await run(["command", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Expected output"); -}); +import { Effect } from "effect"; +import { runE2e, testBehaviour } from "./test-context.ts"; + +testBehaviour("does the thing", ({ run, projectRef, orgId, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["command", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Expected output"); + }), + signal, + ), +); ``` The `workspace` fixture is what triggers scenario setup — if you destructure `workspace` or `run` (which depends on `workspace`), the scenario is automatically started before the test and cleaned up after. +Always return `runE2e(...)` from the body: Vitest does not run a returned Effect, so the harness fails a test that requests `run` or `runNoProjectId` and passes without executing it. A custom runner added with `testBehaviour.extend` gets the same check by wrapping its CLI Effect in `runGuard.track(...)`, as `stackRun` does. Pass the test's `signal` so a Vitest timeout interrupts the body. + +This workspace is under the Effect lint (`.oxlintrc.effect.json`, warnings are errors), which rejects `async` test bodies, global `fetch`, `process.env` and `JSON.parse` in Effect code. Decode JSON stdout with `decodeJsonOutput(schema)(result.stdout)`, which reports a stdout excerpt on failure. + Available fixtures: -- `run(cmd)` — executes the CLI via the relay server +- `run(cmd)` — returns an Effect that executes the CLI via the relay server - `projectRef` — 20-char project ref (real in record mode, default in replay) - `orgId` — org slug (real in record mode, default in replay) - `apiUrl` — the relay server base URL, for direct `/_ctrl/` calls @@ -85,15 +95,23 @@ Available fixtures: Inject errors via `/_ctrl/error-all` before the `run` call: ```typescript -testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["command", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); -}); +import { Effect } from "effect"; +import { PROJECT_REF } from "./env.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; + +testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["command", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), +); ``` Error injection tests can use `PROJECT_REF` directly (not `projectRef`) because no real API call reaches staging — the error is injected before the relay server checks the fixture. Their scenario fixture is always `[]` (empty). @@ -103,15 +121,21 @@ Error injection tests can use `PROJECT_REF` directly (not `projectRef`) because If a test depends on timing (e.g. deleting a project that was just created won't work because it's not yet provisioned), use `skipIf(isRecording)` and write a static fixture instead: ```typescript +import { Effect } from "effect"; import { isRecording } from "./env.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; testBehaviour.skipIf(isRecording)( "deletes project with --yes flag", - async ({ run, projectRef }) => { - const result = await run(["projects", "delete", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted project"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "delete", projectRef, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted project"); + }), + signal, + ), ); ``` @@ -180,32 +204,3 @@ per-process, so each shard still has deterministic intra-shard ordering. is a single-job operation; parallel shards would race on the shared `fixtures/recorded/` directory. The `record` script does not accept `--shard`. - -## Go binary version requirement - -The CLI proxies a fixed, small set of commands to a Go binary (`SUPABASE_GO_BINARY` → bundled package binary → system `supabase`) — as of CLI-1970, `apps/cli-go/` contains only that residual proxied subset, nothing else, and it is slated for cleanup and removal (the surface only shrinks; never add tests that grow it). If your system `supabase` binary predates a flag or subcommand change on one of these, `testBehaviour` tests for it will fail with "unknown command" or "unknown flag". - -Build the Go CLI from source and point `SUPABASE_GO_BINARY` at it: - -```sh -(cd apps/cli-go && go build -o /tmp/supabase-test-binary .) - -# Replay -SUPABASE_GO_BINARY=/tmp/supabase-test-binary \ - pnpm exec turbo run @supabase/cli-e2e#test:e2e:run - -# Record -SUPABASE_GO_BINARY=/tmp/supabase-test-binary \ - SUPABASE_ACCESS_TOKEN=sbp_... SUPABASE_STAGING_URL=https://api.supabase.green \ - pnpm run record -``` - -`SUPABASE_GO_BINARY` is inherited by the CLI subprocess via `exec()` in the harness, so you only need to set it once in the shell. - -Commands currently requiring this — the full proxied surface, nothing else needs a Go binary at all: - -- `db diff` (for `--use-pg-schema`) -- `db branch create`, `db branch delete`, `db branch list`, `db branch switch` -- `db remote changes` -- `gen keys` -- `functions download` (for the hidden `--legacy-bundle` flag) diff --git a/apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-remote-exits-non-zero-on-connection-refused-with-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-push-remote-exits-non-zero-on-connection-refused-with-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-401/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-401/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-401/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-401/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-403/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-403/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-403/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-403/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json similarity index 97% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json index 4ce1bb15f0..820418ae8a 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json @@ -13,7 +13,8 @@ "user-agent": "SupabaseCLI/" }, "body": { - "query": "SELECT 1" + "query": "SELECT 1", + "read_only": true } }, "response": { diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json similarity index 97% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json index 533edf3271..37938ca646 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json @@ -13,7 +13,8 @@ "user-agent": "SupabaseCLI/" }, "body": { - "query": "SELECT 1" + "query": "SELECT 1", + "read_only": true } }, "response": { diff --git a/apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-no-data-source-specified/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-when-the-local-stack-is-not-running/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-no-data-source-specified/interactions.json rename to apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-when-the-local-stack-is-not-running/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-db-url-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-db-url-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json new file mode 100644 index 0000000000..0790d147ea --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json @@ -0,0 +1,69 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "lints": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "116", + "x-ratelimit-reset": "54" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 28e474eaa3..616559a701 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -7,11 +7,15 @@ "test": "pnpm run test:e2e", "test:e2e": "pnpm exec turbo run supabase#build && pnpm exec turbo run @supabase/cli-e2e#test:e2e:run --only --", "test:e2e:run": "bun --bun vitest run", + "replay:serve": "bun scripts/replay-serve.ts", "record": "RECORD=true pnpm run test:e2e:run", "types:check": "tsc --noEmit" }, "dependencies": { - "@supabase/cli-test-helpers": "workspace:*" + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@supabase/cli-test-helpers": "workspace:*", + "effect": "catalog:" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/apps/cli-e2e/scripts/replay-serve.ts b/apps/cli-e2e/scripts/replay-serve.ts new file mode 100644 index 0000000000..441236a9e2 --- /dev/null +++ b/apps/cli-e2e/scripts/replay-serve.ts @@ -0,0 +1,55 @@ +import { Config, Console, Data, Effect, Fiber, Schema } from "effect"; +import { startReplayServer } from "../src/server/replay-server.ts"; + +const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; + +class ReplayServeError extends Data.TaggedError("ReplayServeError")<{ readonly message: string }> {} + +const parsePort = (argv: readonly string[]) => { + const index = argv.indexOf("--port"); + if (index === -1) return Effect.succeed(0); + const port = Number(argv[index + 1]); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return Effect.fail( + new ReplayServeError({ + message: `--port expects an integer between 0 and 65535, got "${argv[index + 1]}"`, + }), + ); + } + return Effect.succeed(port); +}; + +const stopSignal = Effect.callback((resume) => { + const stop = () => resume(Effect.void); + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + return Effect.sync(() => { + process.off("SIGTERM", stop); + process.off("SIGINT", stop); + }); +}); + +const main = Effect.gen(function* () { + // Recording mode wipes the committed fixtures, so a standalone server never runs in it. + if ((yield* Config.String("RECORD").pipe(Config.withDefault(""))) === "true") { + return yield* new ReplayServeError({ + message: "replay-serve does not support RECORD; unset it to serve the fixtures", + }); + } + + const server = yield* startReplayServer({ + fixturesDir: FIXTURES_DIR, + hostname: "127.0.0.1", + port: yield* parsePort(process.argv.slice(2)), + record: false, + stagingUrl: undefined, + }); + + const stopped = yield* stopSignal.pipe(Effect.forkChild({ startImmediately: true })); + yield* Console.log( + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ url: server.url }), + ); + yield* Fiber.join(stopped); +}); + +await Effect.runPromise(Effect.scoped(main)); diff --git a/apps/cli-e2e/src/server/fixture-loader.ts b/apps/cli-e2e/src/server/fixture-loader.ts index 50da310d26..7fe1a82eb7 100644 --- a/apps/cli-e2e/src/server/fixture-loader.ts +++ b/apps/cli-e2e/src/server/fixture-loader.ts @@ -1,59 +1,86 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -export interface FixtureRequest { - method: string; - path: string; - query: Record; - headers: Record; - body: unknown; -} +import { Data, Effect, FileSystem, Formatter, Path, Schema } from "effect"; -export interface FixtureResponse { - status: number; - headers: Record; - body: unknown; -} +/* Hand-authored fixtures may omit headers (served as none) and the response body (served empty). */ +const FixtureHeaders = Schema.Record(Schema.String, Schema.String).pipe( + Schema.withDecodingDefaultKey(Effect.sync(() => ({}))), +); -export interface FixtureEntry { - request: FixtureRequest; - response: FixtureResponse; -} +const FixtureRequest = Schema.Struct({ + method: Schema.String, + path: Schema.mutableKey(Schema.String), + query: Schema.Record(Schema.String, Schema.String), + headers: FixtureHeaders, + body: Schema.Unknown, +}); + +const FixtureResponse = Schema.Struct({ + status: Schema.Finite, + headers: FixtureHeaders, + body: Schema.optionalKey(Schema.Unknown), +}); + +const FixtureEntry = Schema.Struct({ request: FixtureRequest, response: FixtureResponse }); + +export type FixtureRequest = typeof FixtureRequest.Type; +export type FixtureResponse = typeof FixtureResponse.Type; +export type FixtureEntry = typeof FixtureEntry.Type; /** All fixtures indexed by their key (e.g. "GET_v1_projects"), each holding * an ordered queue of entries (for sequential calls to the same endpoint). */ export type FixtureStore = Map; +/* Mirrors `existsSync`: any failure to reach the path counts as absent. */ +export const pathExists = (fs: FileSystem.FileSystem, path: string) => + fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + +/** A fixture file is missing or does not hold valid JSON. */ +class FixtureFileError extends Data.Error<{ readonly message: string; readonly cause?: unknown }> {} + /** Load an ordered scenario from scenarios//interactions.json. * Returns null if the scenario file does not exist — caller decides whether * to fail loudly or fall back to per-endpoint fixtures. */ -export function loadScenario(scenariosDir: string, name: string): FixtureEntry[] | null { - const scenarioFile = join(scenariosDir, name, "interactions.json"); - if (!existsSync(scenarioFile)) return null; - return parseFixtureFile(scenarioFile); -} +export const loadScenario = Effect.fn("FixtureLoader.loadScenario")(function* ( + scenariosDir: string, + name: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scenarioFile = path.join(scenariosDir, name, "interactions.json"); + if (!(yield* pathExists(fs, scenarioFile))) return null; + const entries: FixtureEntry[] = yield* parseFixtureFile( + scenarioFile, + Schema.mutable(Schema.Array(FixtureEntry)), + ); + return entries; +}); /** Load all fixture pairs from the recorded/ directory into a FixtureStore. * Fails fast with a descriptive error if any fixture file is malformed. */ -export function loadFixtures(fixturesDir: string): FixtureStore { - const recordedDir = join(fixturesDir, "recorded"); +export const loadFixtures = Effect.fn("FixtureLoader.loadFixtures")(function* ( + fixturesDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const recordedDir = path.join(fixturesDir, "recorded"); const store: FixtureStore = new Map(); - if (!existsSync(recordedDir)) { + if (!(yield* pathExists(fs, recordedDir))) { return store; } - const keys = readdirSync(recordedDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); + const keys: string[] = []; + for (const name of yield* fs.readDirectory(recordedDir)) { + const info = yield* fs.stat(path.join(recordedDir, name)); + if (info.type === "Directory") keys.push(name); + } for (const key of keys) { - const keyDir = join(recordedDir, key); + const keyDir = path.join(recordedDir, key); const entries: FixtureEntry[] = []; // Collect numbered pairs: 1.request.json/1.response.json, 2.request.json, ... // Also accept "default" as an alias for "1". - const files = readdirSync(keyDir).sort(); + const files = (yield* fs.readDirectory(keyDir)).sort(); const indices = new Set(); for (const file of files) { @@ -62,11 +89,11 @@ export function loadFixtures(fixturesDir: string): FixtureStore { } for (const index of [...indices].sort(compareIndices)) { - const reqFile = join(keyDir, `${index}.request.json`); - const resFile = join(keyDir, `${index}.response.json`); + const reqFile = path.join(keyDir, `${index}.request.json`); + const resFile = path.join(keyDir, `${index}.response.json`); - const request = parseFixtureFile(reqFile); - const response = parseFixtureFile(resFile); + const request = yield* parseFixtureFile(reqFile, FixtureRequest); + const response = yield* parseFixtureFile(resFile, FixtureResponse); entries.push({ request, response }); } @@ -75,19 +102,31 @@ export function loadFixtures(fixturesDir: string): FixtureStore { } } + yield* Effect.annotateCurrentSpan("fixture.key_count", store.size); return store; -} - -function parseFixtureFile(filePath: string): T { - if (!existsSync(filePath)) { - throw new Error(`Missing fixture file: ${filePath}`); - } - try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; - } catch (cause) { - throw new Error(`Malformed fixture file: ${filePath}`, { cause }); +}); + +const parseFixtureFile = Effect.fnUntraced(function* ( + filePath: string, + schema: S, +) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* pathExists(fs, filePath))) { + return yield* new FixtureFileError({ message: `Missing fixture file: ${filePath}` }); } -} + const malformed = (cause: unknown) => + new FixtureFileError({ message: `Malformed fixture file: ${filePath}`, cause }); + const bytes = yield* fs.readFile(filePath).pipe(Effect.mapError(malformed)); + const text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(text).pipe( + Effect.mapError( + (error) => + new FixtureFileError({ + message: `Malformed fixture file: ${filePath}\n ${error.message}: ${Formatter.format(text.slice(0, 200))}`, + }), + ), + ); +}); /** Sort indices so "default" comes first, then numerically. */ function compareIndices(a: string, b: string): number { diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index b419eed1b1..931fb8d8c4 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -22,6 +22,8 @@ * - Terminate ('X') → closes connection gracefully */ +import { Console, Effect } from "effect"; + export interface PgFixture { /** Lowercase column names, matching the field names the inspect commands map query results onto. */ columns: string[]; @@ -367,7 +369,7 @@ function processMessages(socket: Bun.Socket, getState: () => PgMockS socket.data.buf = buf.slice(totalLen); if (msgType === 0x51) { - // 'Q' — simple query (used by pgconn.CopyTo and simple-protocol callers) + // 'Q' — simple query (used by COPY and simple-protocol callers) const query = payload.slice(0, payload.length - 1).toString("utf8"); const isProbe = /^\s*select\s+1\s*;?\s*$/i.test(query); const isCopy = /^\s*COPY\s+/i.test(query); @@ -440,13 +442,13 @@ export function startPgMock(): PgMockHandle { try { processMessages(socket, () => state); } catch (err) { - console.error("[pg-mock] error processing message:", err); + Effect.runSync(Console.error("[pg-mock] error processing message:", err)); socket.end(); } }, close(_socket) {}, error(_socket, err) { - console.error("[pg-mock] socket error:", err); + Effect.runSync(Console.error("[pg-mock] socket error:", err)); }, }, }); diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 8706bc8736..b7f9e90813 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -1,14 +1,43 @@ -import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { request as httpRequest } from "node:http"; -import { join } from "node:path"; import { URL } from "node:url"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { NodeHttpClient } from "@effect/platform-node"; +import { + Clock, + Console, + Context, + Data, + DateTime, + Deferred, + Duration, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + LayerMap, + Option, + Path, + Schedule, + Schema, + Scope, + Semaphore, + Stream, +} from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpMethod, +} from "effect/http"; import type { FixtureEntry, FixtureRequest, FixtureResponse, FixtureStore, } from "./fixture-loader.ts"; -import { loadFixtures, loadScenario } from "./fixture-loader.ts"; +import { loadFixtures, loadScenario, pathExists } from "./fixture-loader.ts"; import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -63,6 +92,44 @@ interface RawBody { base64: string; } +/** The replay server could not start: record mode without a staging URL, or no listener. */ +class ReplayServerStartError extends Data.TaggedError("ReplayServerStartError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** An incoming request or control payload body could not be read. */ +class RequestBodyError extends Data.TaggedError("RequestBodyError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The record-mode proxy to staging failed before a response arrived. */ +class StagingProxyError extends Data.TaggedError("StagingProxyError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** A recorded interaction could not be serialized into its fixture files. */ +class FixtureWriteError extends Data.TaggedError("FixtureWriteError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The record-mode Docker socket exchange failed; messages are the `node:http` errors. */ +class DockerSocketError extends Data.TaggedError("DockerSocketError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +/* `JSON.stringify`/`JSON.parse` through Schema; fixture files keep their 2-space indentation. */ +const JsonText = Schema.fromJsonString(Schema.Unknown); +const encodeJson = Schema.encodeEffect(JsonText); +const decodeJson = Schema.decodeEffect(JsonText); +const encodeFixtureJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + function isMultipartBody(body: unknown): body is MultipartBody { return ( typeof body === "object" && @@ -125,7 +192,6 @@ function buildMultipartResponse( interface ReplayServerHandle { readonly url: string; readonly port: number; - stop(): Promise; /** Return all requests received since the last clearRequests() call. */ getRequests(): RecordedRequest[]; /** Clear the recorded request log and reset fixture sequence counters. */ @@ -153,27 +219,64 @@ interface ReplayServerOptions { fixturesDir: string; /** Port to listen on (0 = random). */ port?: number; + /** Interface to bind; Bun binds all interfaces when omitted. */ + hostname?: string; /** Optional Postgres mock server to control via /_ctrl/pg-* endpoints. */ pgMock?: PgMockHandle; + /** Proxy to staging and record fixtures instead of replaying them. */ + record: boolean; + /** Staging Management API base URL; required when recording. */ + stagingUrl: string | undefined; } -export async function startReplayServer(options: ReplayServerOptions): Promise { - const isRecord = process.env["RECORD"] === "true"; - const stagingUrl = process.env["SUPABASE_STAGING_URL"]; +/* The Docker leg uses node:http because Bun's fetch sorts response headers, while fixtures record them in arrival order. */ +class DockerClients extends LayerMap.Service()("cli-e2e/DockerClients", { + lookup: (socketPath: string) => { + /* AgentOptions does not declare socketPath, but the pinned Bun's node:http reads it from + the agent options; Bun 1.3 ignores it and connects to localhost:80. */ + const agentOptions = { keepAlive: true, socketPath }; + return Layer.fresh(NodeHttpClient.layerNodeHttpNoAgent).pipe( + Layer.provide(NodeHttpClient.layerAgentOptions(agentOptions)), + ); + }, + idleTimeToLive: Duration.infinity, +}) {} + +const serverLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + DockerClients.layer, +); + +/** Runs the replay server until the caller's scope closes, which stops it and releases its layer. */ +export const startReplayServer = (options: ReplayServerOptions) => + Layer.build(serverLayer).pipe( + Effect.flatMap((context) => makeReplayServer(options).pipe(Effect.provideContext(context))), + ); + +const makeReplayServer = Effect.fn("ReplayServer.start")(function* (options: ReplayServerOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isRecord = options.record; + const stagingUrl = options.stagingUrl; if (isRecord && !stagingUrl) { - throw new Error("RECORD=true requires SUPABASE_STAGING_URL to be set"); + return yield* new ReplayServerStartError({ + message: "RECORD=true requires SUPABASE_STAGING_URL to be set", + }); } // In record mode, wipe both fixture stores before serving any traffic, so the // recording session repopulates only what the running tests exercise and any // orphan from a prior session is dropped. if (isRecord) { - rmSync(join(options.fixturesDir, "recorded"), { recursive: true, force: true }); - rmSync(join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); + yield* fs.remove(path.join(options.fixturesDir, "recorded"), { recursive: true, force: true }); + yield* fs.remove(path.join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); } - const store: FixtureStore = isRecord ? new Map() : loadFixtures(options.fixturesDir); + const store: FixtureStore = isRecord ? new Map() : yield* loadFixtures(options.fixturesDir); const counters: SequenceCounters = new Map(); const requestLog: RecordedRequest[] = []; @@ -186,124 +289,147 @@ export async function startReplayServer(options: ReplayServerOptions): Promise(), + Scope.Scope, + serverScope, + ); function overrideKey(method: string, path: string): string { return `${method.toUpperCase()} ${path}`; } - const server = Bun.serve({ - port: options.port ?? 0, - async fetch(req: Request) { - const url = new URL(req.url); - - // Control plane — not forwarded to CLI or staging - if (url.pathname.startsWith("/_ctrl/")) { - return handleControl(req, url, { - requestLog, - counters, - errorOverrides, - rateLimitOverrides, - scenario, - globalErrorRef, - isRecord, - fixturesDir: options.fixturesDir, - pgMock: options.pgMock, - }); - } + const handleRequest = Effect.fnUntraced(function* (req: Request) { + const url = new URL(req.url); + + // Control plane — not forwarded to CLI or staging + if (url.pathname.startsWith("/_ctrl/")) { + return yield* handleControl(req, url, { + requestLog, + counters, + errorOverrides, + rateLimitOverrides, + scenario, + globalErrorRef, + isRecord, + fixturesDir: options.fixturesDir, + pgMock: options.pgMock, + recordLock, + }); + } - const method = req.method; - const pathname = url.pathname; - const query = Object.fromEntries(url.searchParams.entries()); - const requestHeaders = Object.fromEntries(req.headers.entries()); - - let requestBody: unknown = null; - let rawBody: ReadableStream | null = null; - const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - try { - requestBody = await req.json(); - } catch { - // not JSON — leave as null - } - } else { - rawBody = req.body; - } + const method = req.method; + const pathname = url.pathname; + const query = Object.fromEntries(url.searchParams.entries()); + const requestHeaders = Object.fromEntries(req.headers.entries()); + + let requestBody: unknown = null; + let rawBody: ReadableStream | null = null; + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + // not JSON — leave as null + requestBody = yield* readJson(req).pipe(Effect.orElseSucceed(() => null)); + } else { + rawBody = req.body; + } - requestLog.push({ - method, - pathname, - query, - headers: requestHeaders, - body: requestBody, - timestamp: new Date().toISOString(), - }); + requestLog.push({ + method, + pathname, + query, + headers: requestHeaders, + body: requestBody, + timestamp: DateTime.formatIso(yield* DateTime.now), + }); - // Global error override — returned for all API requests regardless of endpoint. - if (globalErrorRef.value) { - return injectedErrorResponse(globalErrorRef.value); - } + // Global error override — returned for all API requests regardless of endpoint. + if (globalErrorRef.value) { + return injectedErrorResponse(globalErrorRef.value); + } - // Per-endpoint error overrides - const errKey = overrideKey(method, pathname); - const errorOverride = errorOverrides.get(errKey); - if (errorOverride) { - return injectedErrorResponse(errorOverride); - } + // Per-endpoint error overrides + const errKey = overrideKey(method, pathname); + const errorOverride = errorOverrides.get(errKey); + if (errorOverride) { + return injectedErrorResponse(errorOverride); + } - const rateLimitOverride = rateLimitOverrides.get(pathname); - if (rateLimitOverride) { - return new Response(JSON.stringify({ message: "Too Many Requests" }), { + const rateLimitOverride = rateLimitOverrides.get(pathname); + if (rateLimitOverride) { + return Response.json( + { message: "Too Many Requests" }, + { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(rateLimitOverride.retryAfterSeconds), }, - }); - } + }, + ); + } - if (isRecord) { - return proxyAndRecord( - method, - pathname, - query, - requestHeaders, - requestBody, - rawBody, - stagingUrl!, - options.fixturesDir, - recordedKeys, - scenario, - storageProxyUrl, - storageProxyAuth, - dockerProxySocketPath, - ); - } + if (isRecord) { + return yield* proxyAndRecord( + method, + pathname, + query, + requestHeaders, + requestBody, + rawBody, + stagingUrl!, + options.fixturesDir, + recordedKeys, + scenario, + recordLock, + storageProxyUrl, + storageProxyAuth, + dockerProxySocketPath, + ); + } - // Replay mode: scenario takes priority for matching requests; out-of-band - // requests (e.g. post-command telemetry calls) fall through to the - // per-endpoint fixture store. - if (scenario.name !== null) { - const expected = scenario.queue[scenario.index]; - if ( - expected !== undefined && - expected.request.method.toUpperCase() === method.toUpperCase() && - expected.request.path === normalizeUrlPath(pathname) - ) { - return serveFromScenario(scenario, method, pathname, { query, body: requestBody }); - } + // Replay mode: scenario takes priority for matching requests; out-of-band + // requests (e.g. post-command telemetry calls) fall through to the + // per-endpoint fixture store. + if (scenario.name !== null) { + const expected = scenario.queue[scenario.index]; + if ( + expected !== undefined && + expected.request.method.toUpperCase() === method.toUpperCase() && + expected.request.path === normalizeUrlPath(pathname) + ) { + return serveFromScenario(scenario, method, pathname, { query, body: requestBody }); } + } - return serveFromFixtures(store, counters, method, pathname, { query, body: requestBody }); - }, + return serveFromFixtures(store, counters, method, pathname, { query, body: requestBody }); }); + /* Bun's graceful stop drains in-flight responses before serverScope closes. */ + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + port: options.port ?? 0, + ...(options.hostname === undefined ? {} : { hostname: options.hostname }), + fetch: (req: Request) => Effect.runPromiseWith(services)(handleRequest(req)), + }), + catch: (cause) => new ReplayServerStartError({ message: causeMessage(cause), cause }), + }), + (server) => Effect.promise(() => server.stop()), + ); + const port = server.port ?? 0; const serverUrl = `http://127.0.0.1:${port}`; - return { + const handle: ReplayServerHandle = { url: serverUrl, port, - stop: () => server.stop(), getRequests: () => [...requestLog], clearRequests: () => { requestLog.length = 0; @@ -330,7 +456,8 @@ export async function startReplayServer(options: ReplayServerOptions): Promise): Record { + const responseHeaders: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + } + return responseHeaders; +} + +const proxyAndRecord = Effect.fn("ReplayServer.proxyAndRecord")(function* ( method: string, pathname: string, query: Record, @@ -369,10 +504,11 @@ async function proxyAndRecord( fixturesDir: string, recordedKeys: Set, scenario: ScenarioState, + recordLock: Semaphore.Semaphore, storageProxyUrl?: string, storageProxyAuth?: string, dockerProxySocketPath?: string, -): Promise { +) { const isStoragePath = pathname.startsWith("/storage/v1/"); // Docker versioned API paths start with /v1. (decimal), not /v1/ (slash, the // management API). /_ping is the Docker health-check endpoint (no version prefix). @@ -385,7 +521,11 @@ async function proxyAndRecord( } if (isDockerPath && dockerProxySocketPath) { - const dockerResult = await proxyToDockerSocket( + // Stream the response back to the caller immediately. Recording happens + // asynchronously after the body has fully drained — long-running streaming + // endpoints (image pull progress, container logs) are not blocked on it. + // The stream ends for the caller only once the recording is written. + const dockerResult = yield* proxyToDockerSocket( dockerProxySocketPath, method, pathname, @@ -393,32 +533,26 @@ async function proxyAndRecord( upstreamHeaders, requestBody, rawBody, + (responseStatus, headers, body) => + recordDockerInteraction({ + body, + method, + pathname, + query, + requestHeaders, + requestBody, + responseStatus, + responseHeaders: stripResponseHeaders(headers), + fixturesDir, + recordedKeys, + scenario, + recordLock, + }), ); - const responseHeaders: Record = {}; - for (const [k, v] of Object.entries(dockerResult.headers)) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; - } - - // Stream the response back to the caller immediately. Recording happens - // asynchronously after the body has fully drained — long-running streaming - // endpoints (image pull progress, container logs) are not blocked on it. - void recordDockerInteraction({ - bodyPromise: dockerResult.bodyPromise, - method, - pathname, - query, - requestHeaders, - requestBody, - responseStatus: dockerResult.status, - responseHeaders, - fixturesDir, - recordedKeys, - scenario, - }); return new Response(dockerResult.stream, { status: dockerResult.status, - headers: responseHeaders, + headers: stripResponseHeaders(dockerResult.headers), }); } @@ -431,29 +565,39 @@ async function proxyAndRecord( upstreamHeaders["authorization"] = `Bearer ${storageProxyAuth}`; } - const upstreamRes = await fetch(targetUrl.toString(), { - method, - headers: upstreamHeaders, - body: - method !== "GET" && method !== "HEAD" - ? requestBody != null - ? JSON.stringify(requestBody) - : (rawBody ?? undefined) - : undefined, - }); - - const responseBody = await upstreamRes - .clone() - .json() - .catch(() => null); - const responseHeaders: Record = {}; - for (const [k, v] of upstreamRes.headers.entries()) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + if (!HttpMethod.isHttpMethod(method)) { + return yield* new StagingProxyError({ message: `Unsupported HTTP method: ${method}` }); } + const upstreamBody = + method !== "GET" && method !== "HEAD" + ? requestBody != null + ? yield* encodeJson(requestBody).pipe( + Effect.mapError((cause) => new StagingProxyError({ message: cause.message, cause })), + ) + : (rawBody ?? undefined) + : undefined; + const upstreamRes = yield* HttpClient.execute( + HttpClientRequest.make(method)(targetUrl.toString(), { + headers: upstreamHeaders, + body: + upstreamBody === undefined + ? undefined + : HttpBody.raw(upstreamBody, { contentType: upstreamHeaders["content-type"] }), + }), + ).pipe( + Effect.mapError((error) => { + const cause = "cause" in error.reason ? error.reason.cause : error; + return new StagingProxyError({ message: causeMessage(cause), cause }); + }), + ); + + const responseBody = yield* upstreamRes.json.pipe(Effect.orElseSucceed(() => null)); + const responseHeaders = stripResponseHeaders(upstreamRes.headers); const upstreamStatus = upstreamRes.status; - const responseContentType = upstreamRes.headers.get("content-type") ?? "application/json"; + yield* Effect.annotateCurrentSpan("http.response.status_code", upstreamStatus); + const responseContentType = upstreamRes.headers["content-type"] ?? "application/json"; - recordFixture({ + yield* recordFixture({ method, pathname, query, @@ -465,19 +609,20 @@ async function proxyAndRecord( fixturesDir, recordedKeys, scenario, + recordLock, }); return buildApiResponse(responseBody, upstreamStatus, { ...responseHeaders, "content-type": responseContentType, }); -} +}); /** Record a Docker interaction once its streamed body has fully drained. Errors * are logged but do not surface — recording is best-effort and must not affect * the response the caller already received. */ -async function recordDockerInteraction(params: { - bodyPromise: Promise; +const recordDockerInteraction = Effect.fnUntraced(function* (params: { + body: Effect.Effect; method: string; pathname: string; query: Record; @@ -488,33 +633,33 @@ async function recordDockerInteraction(params: { fixturesDir: string; recordedKeys: Set; scenario: ScenarioState; -}): Promise { - let body: Buffer; - try { - body = await params.bodyPromise; - } catch (err) { - console.error( - `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}:`, - err, - ); - return; - } + recordLock: Semaphore.Semaphore; +}) { + const captured = yield* params.body.pipe( + Effect.tapError((err) => + Console.error( + `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}:`, + err, + ), + ), + Effect.option, + ); + if (Option.isNone(captured)) return; + const body = captured.value; let responseBody: unknown; if (body.length === 0) { responseBody = null; } else { - try { - responseBody = JSON.parse(body.toString("utf8")); - } catch { - // Non-JSON or chunked NDJSON (image pull progress, container log frames, - // event streams) — preserve as a base64 envelope so replay can return the - // bytes verbatim instead of silently dropping them. - responseBody = { __type: "raw", base64: body.toString("base64") }; - } + // Non-JSON or chunked NDJSON (image pull progress, container log frames, + // event streams) — preserve as a base64 envelope so replay can return the + // bytes verbatim instead of silently dropping them. + responseBody = yield* decodeJson(body.toString("utf8")).pipe( + Effect.orElseSucceed(() => ({ __type: "raw", base64: body.toString("base64") })), + ); } - recordFixture({ + yield* recordFixture({ method: params.method, pathname: params.pathname, query: params.query, @@ -526,10 +671,19 @@ async function recordDockerInteraction(params: { fixturesDir: params.fixturesDir, recordedKeys: params.recordedKeys, scenario: params.scenario, - }); -} + recordLock: params.recordLock, + }).pipe( + Effect.catch((err) => + Console.error( + `[replay-server] failed to record Docker interaction for ${params.method} ${params.pathname}:`, + err, + ), + ), + ); +}); -function recordFixture(params: { +/* Once the record lock is held, the write finishes even if the server stops mid-recording. */ +const recordFixture = (params: { method: string; pathname: string; query: Record; @@ -541,8 +695,28 @@ function recordFixture(params: { fixturesDir: string; recordedKeys: Set; scenario: ScenarioState; -}): void { - const rawPair = JSON.stringify({ + recordLock: Semaphore.Semaphore; +}) => params.recordLock.withPermit(writeFixture(params).pipe(Effect.uninterruptible)); + +const fixtureWriteError = (cause: Schema.SchemaError) => + new FixtureWriteError({ message: cause.message, cause }); + +const writeFixture = Effect.fn("ReplayServer.writeFixture")(function* (params: { + method: string; + pathname: string; + query: Record; + requestHeaders: Record; + requestBody: unknown; + responseStatus: number; + responseHeaders: Record; + responseBody: unknown; + fixturesDir: string; + recordedKeys: Set; + scenario: ScenarioState; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rawPair = yield* encodeJson({ request: { method: params.method, path: params.pathname, @@ -555,9 +729,9 @@ function recordFixture(params: { headers: params.responseHeaders, body: params.responseBody, }, - }); + }).pipe(Effect.mapError(fixtureWriteError)); const { output } = applyPlaceholders(rawPair); - const normalized = JSON.parse(output) as { + const normalized = (yield* decodeJson(output).pipe(Effect.mapError(fixtureWriteError))) as { request: FixtureRequest; response: FixtureResponse; }; @@ -565,39 +739,39 @@ function recordFixture(params: { normalized.request.path = normalizeUrlPath(params.pathname); const key = fixtureKey(params.method, params.pathname); - const keyDir = join(params.fixturesDir, "recorded", key); + const keyDir = path.join(params.fixturesDir, "recorded", key); if (!params.recordedKeys.has(key)) { params.recordedKeys.add(key); - if (existsSync(keyDir)) { - for (const file of readdirSync(keyDir)) { - unlinkSync(join(keyDir, file)); + if (yield* pathExists(fs, keyDir)) { + for (const file of yield* fs.readDirectory(keyDir)) { + yield* fs.remove(path.join(keyDir, file)); } } } - mkdirSync(keyDir, { recursive: true }); + yield* fs.makeDirectory(keyDir, { recursive: true }); - const nextIndex = nextFixtureIndex(keyDir); + const nextIndex = yield* nextFixtureIndex(keyDir); // Beyond the cap, the proxied response is still returned but no longer persisted. if (nextIndex <= MAX_FIXTURE_ENTRIES) { const indexStr = nextIndex === 1 ? "default" : String(nextIndex); - writeFileSync( - join(keyDir, `${indexStr}.request.json`), - JSON.stringify(normalized.request, null, 2), + yield* fs.writeFileString( + path.join(keyDir, `${indexStr}.request.json`), + yield* encodeFixtureJson(normalized.request).pipe(Effect.mapError(fixtureWriteError)), ); - writeFileSync( - join(keyDir, `${indexStr}.response.json`), - JSON.stringify(normalized.response, null, 2), + yield* fs.writeFileString( + path.join(keyDir, `${indexStr}.response.json`), + yield* encodeFixtureJson(normalized.response).pipe(Effect.mapError(fixtureWriteError)), ); } if (params.scenario.name !== null) { params.scenario.log.push({ request: normalized.request, response: normalized.response }); - writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); + yield* writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); } -} +}); interface DockerProxyResult { status: number; @@ -606,18 +780,32 @@ interface DockerProxyResult { * long-running streaming endpoints (image pull progress, container logs, * events) are not blocked on the full upstream body. */ stream: ReadableStream; - /** Resolves with the full concatenated body once the upstream stream ends. - * Used by `proxyAndRecord` to write the fixture *after* the response has - * flushed to the caller. */ - bodyPromise: Promise; } +/** Receives the full concatenated body (or its failure) once the upstream + * stream ends. Used by `proxyAndRecord` to write the fixture *after* the + * body has drained to the caller and before the caller's stream ends. */ +type DockerBodyHandler = ( + status: number, + headers: Record, + body: Exit.Exit, +) => Effect.Effect; + /** Idle timeout: abort if the upstream socket goes silent for this long. Only * kills truly stuck connections — a slow but still-active operation (e.g. a * first-time image pull) stays alive as long as it keeps emitting progress. */ const DOCKER_SOCKET_IDLE_TIMEOUT_MS = 60_000; -async function proxyToDockerSocket( +const dockerFailure = (error: HttpClientError.HttpClientError) => { + const cause = "cause" in error.reason ? error.reason.cause : error; + return new DockerSocketError({ message: causeMessage(cause), cause }); +}; + +/* The error `node:http` raised on a response body cut short by an idle timeout or the + caller going away. */ +const abortedError = () => new DockerSocketError({ message: "aborted" }); + +const proxyToDockerSocket = Effect.fn("ReplayServer.proxyToDockerSocket")(function* ( socketPath: string, method: string, pathname: string, @@ -625,15 +813,22 @@ async function proxyToDockerSocket( headers: Record, requestBody: unknown, rawBody: ReadableStream | null, -): Promise { + onBody: DockerBodyHandler, +) { const qStr = new URLSearchParams(query).toString(); const path = qStr ? `${pathname}?${qStr}` : pathname; let bodyBuf: Buffer | undefined; if (requestBody != null) { - bodyBuf = Buffer.from(JSON.stringify(requestBody), "utf8"); + const json = yield* encodeJson(requestBody).pipe( + Effect.mapError((cause) => new DockerSocketError({ message: cause.message, cause })), + ); + bodyBuf = Buffer.from(json, "utf8"); } else if (rawBody != null) { - const ab = await new Response(rawBody).arrayBuffer(); + const ab = yield* Effect.tryPromise({ + try: () => new Response(rawBody).arrayBuffer(), + catch: (cause) => new RequestBodyError({ message: causeMessage(cause), cause }), + }); bodyBuf = Buffer.from(ab); } @@ -645,91 +840,106 @@ async function proxyToDockerSocket( "keep-alive", "content-length", ]); - const reqHeaders: Record = {}; + const reqHeaders: Record = {}; for (const [k, v] of Object.entries(headers)) { if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = v; } - if (bodyBuf) { - reqHeaders["Content-Length"] = bodyBuf.length; - } - - return new Promise((resolve, reject) => { - const req = httpRequest({ socketPath, method, path, headers: reqHeaders }, (res) => { - if (res.statusCode == null) { - reject(new Error("Docker socket returned response with no status code")); - return; - } - const resHeaders: Record = {}; - for (const [k, v] of Object.entries(res.headers)) { - if (typeof v === "string") resHeaders[k] = v; - else if (Array.isArray(v)) resHeaders[k] = v.join(", "); - } + if (!HttpMethod.isHttpMethod(method)) { + return yield* new DockerSocketError({ message: `Unsupported HTTP method: ${method}` }); + } + const response = yield* HttpClient.execute( + HttpClientRequest.make(method)(`http://localhost${path}`, { + headers: reqHeaders, + body: + bodyBuf === undefined + ? undefined + : HttpBody.raw(bodyBuf, { + contentType: reqHeaders["content-type"], + contentLength: bodyBuf.length, + }), + }), + ).pipe(Effect.provide(DockerClients.get(socketPath)), Effect.mapError(dockerFailure)); + yield* Effect.annotateCurrentSpan("http.response.status_code", response.status); - const chunks: Buffer[] = []; - let bodyResolve: (buf: Buffer) => void = () => {}; - let bodyReject: (err: Error) => void = () => {}; - const bodyPromise = new Promise((res2, rej2) => { - bodyResolve = res2; - bodyReject = rej2; - }); + const chunks: Uint8Array[] = []; + let lastActivity = yield* Clock.currentTimeMillis; + const settled = yield* Deferred.make(); + let cancelled = false; + let controller: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + cancelled = true; + Deferred.doneUnsafe(settled, Exit.fail(abortedError())); + }, + }); + const emit = Effect.fnUntraced(function* (chunk: Uint8Array) { + lastActivity = yield* Clock.currentTimeMillis; + chunks.push(chunk); + if (!cancelled) controller?.enqueue(new Uint8Array(chunk)); + }); - let lastActivity = Date.now(); - const idleTimer = setInterval(() => { - if (Date.now() - lastActivity > DOCKER_SOCKET_IDLE_TIMEOUT_MS) { - clearInterval(idleTimer); - req.destroy(new Error(`Docker socket idle for ${DOCKER_SOCKET_IDLE_TIMEOUT_MS / 1000}s`)); - } - }, 5_000); - - const stream = new ReadableStream({ - start(controller) { - res.on("data", (chunk: Buffer) => { - lastActivity = Date.now(); - chunks.push(chunk); - controller.enqueue(new Uint8Array(chunk)); - }); - res.on("end", () => { - clearInterval(idleTimer); - controller.close(); - bodyResolve(Buffer.concat(chunks)); - }); - res.on("error", (err) => { - clearInterval(idleTimer); - controller.error(err); - bodyReject(err); - }); - }, - cancel(reason) { - clearInterval(idleTimer); - req.destroy(reason instanceof Error ? reason : undefined); - }, - }); + const relay = Effect.gen(function* () { + yield* Stream.runForEach(response.stream, emit).pipe( + Effect.mapError(dockerFailure), + Effect.exit, + Effect.flatMap((exit) => Deferred.done(settled, exit)), + Effect.forkScoped, + ); + yield* Clock.currentTimeMillis.pipe( + Effect.map((now) => now - lastActivity > DOCKER_SOCKET_IDLE_TIMEOUT_MS), + Effect.repeat({ schedule: Schedule.fixed("5 seconds"), until: (idle) => idle }), + Effect.andThen(Deferred.fail(settled, abortedError())), + Effect.forkScoped, + ); + yield* Deferred.await(settled).pipe( + Effect.matchEffect({ + onSuccess: () => + onBody(response.status, response.headers, Exit.succeed(Buffer.concat(chunks))).pipe( + Effect.andThen( + Effect.sync(() => { + if (!cancelled) controller?.close(); + }), + ), + ), + onFailure: (error) => + Effect.sync(() => controller?.error(error)).pipe( + Effect.andThen(onBody(response.status, response.headers, Exit.fail(error))), + ), + }), + ); + }).pipe(Effect.scoped); + const relayFiber = yield* Effect.forkScoped(relay); + /* node:http ends a bodiless response before the caller sees it, so it is recorded and closed first. */ + if (method === "HEAD" || response.status === 204 || response.status === 304) { + yield* Fiber.await(relayFiber); + } - resolve({ status: res.statusCode, headers: resHeaders, stream, bodyPromise }); - }); - req.on("error", (err) => { - req.destroy(); - reject(err); - }); - if (bodyBuf) req.write(bodyBuf); - req.end(); - }); -} + return { status: response.status, headers: response.headers, stream } satisfies DockerProxyResult; +}); -function writeScenarioInteractions( +const writeScenarioInteractions = Effect.fn("ReplayServer.writeScenarioInteractions")(function* ( fixturesDir: string, scenarioName: string, interactions: Array<{ request: FixtureRequest; response: FixtureResponse }>, -): void { - const scenarioDir = join(fixturesDir, "scenarios", scenarioName); - mkdirSync(scenarioDir, { recursive: true }); - writeFileSync(join(scenarioDir, "interactions.json"), JSON.stringify(interactions, null, 2)); -} +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scenarioDir = path.join(fixturesDir, "scenarios", scenarioName); + yield* fs.makeDirectory(scenarioDir, { recursive: true }); + yield* fs.writeFileString( + path.join(scenarioDir, "interactions.json"), + yield* encodeFixtureJson(interactions).pipe(Effect.mapError(fixtureWriteError)), + ); +}); -function nextFixtureIndex(keyDir: string): number { - if (!existsSync(keyDir)) return 1; - const files = readdirSync(keyDir); +const nextFixtureIndex = Effect.fnUntraced(function* (keyDir: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* pathExists(fs, keyDir))) return 1; + const files = yield* fs.readDirectory(keyDir); let max = 0; for (const file of files) { const match = file.match(/^(\d+)\.(request|response)\.json$/); @@ -740,7 +950,7 @@ function nextFixtureIndex(keyDir: string): number { if (file.startsWith("default.")) max = Math.max(max, 1); } return max + 1; -} +}); /** Injected 429/5xx responses ask the typed client to retry at once so tests skip its backoff. */ function injectedErrorResponse(error: { status: number; body: unknown }): Response { @@ -899,9 +1109,17 @@ interface ControlContext { isRecord: boolean; fixturesDir: string; pgMock?: PgMockHandle; + recordLock: Semaphore.Semaphore; } -async function handleControl(req: Request, url: URL, ctx: ControlContext): Promise { +const readJson = (req: Request) => + Effect.tryPromise({ + try: () => req.json(), + catch: (cause) => new RequestBodyError({ message: causeMessage(cause), cause }), + }); + +const handleControl = Effect.fnUntraced(function* (req: Request, url: URL, ctx: ControlContext) { + const path = yield* Path.Path; const subpath = url.pathname.slice("/_ctrl".length); if (subpath === "/requests") { @@ -917,45 +1135,51 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi if (subpath === "/scenario") { if (req.method === "POST") { - const { name } = (await req.json()) as { name: string }; - - if (!ctx.isRecord) { - const interactions = loadScenario(join(ctx.fixturesDir, "scenarios"), name); - if (!interactions) { - return new Response( - JSON.stringify({ - message: `Missing scenario: "${name}" — re-record with RECORD=true`, - }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - ctx.scenario.queue = interactions; - } else { - ctx.scenario.queue = []; - ctx.scenario.log = []; - } - - ctx.scenario.name = name; - ctx.scenario.index = 0; - return new Response(null, { status: 204 }); + const { name } = (yield* readJson(req)) as { name: string }; + + return yield* ctx.recordLock.withPermit( + Effect.gen(function* () { + if (!ctx.isRecord) { + const interactions = yield* loadScenario(path.join(ctx.fixturesDir, "scenarios"), name); + if (!interactions) { + return Response.json( + { message: `Missing scenario: "${name}" — re-record with RECORD=true` }, + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + ctx.scenario.queue = interactions; + } else { + ctx.scenario.queue = []; + ctx.scenario.log = []; + } + + ctx.scenario.name = name; + ctx.scenario.index = 0; + return new Response(null, { status: 204 }); + }), + ); } if (req.method === "DELETE") { - // Flush interactions.json even when empty, so a test that hits a global - // error before any API call still gets a scenario file. - if (ctx.isRecord && ctx.scenario.name !== null) { - writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); - } - ctx.scenario.name = null; - ctx.scenario.queue = []; - ctx.scenario.index = 0; - ctx.scenario.log = []; - return new Response(null, { status: 204 }); + return yield* ctx.recordLock.withPermit( + Effect.gen(function* () { + // Flush interactions.json even when empty, so a test that hits a global + // error before any API call still gets a scenario file. + if (ctx.isRecord && ctx.scenario.name !== null) { + yield* writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); + } + ctx.scenario.name = null; + ctx.scenario.queue = []; + ctx.scenario.index = 0; + ctx.scenario.log = []; + return new Response(null, { status: 204 }); + }), + ); } } if (subpath === "/error" && req.method === "POST") { - const body = (await req.json()) as { + const body = (yield* readJson(req)) as { method: string; path: string; status: number; @@ -969,7 +1193,7 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (subpath === "/error-all" && req.method === "POST") { - const body = (await req.json()) as { status: number; body?: unknown }; + const body = (yield* readJson(req)) as { status: number; body?: unknown }; ctx.globalErrorRef.value = { status: body.status, body: body.body ?? { message: "Error" }, @@ -978,7 +1202,7 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (subpath === "/rate-limit" && req.method === "POST") { - const body = (await req.json()) as { + const body = (yield* readJson(req)) as { path: string; retryAfterSeconds: number; }; @@ -996,37 +1220,37 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi if (subpath === "/pg-fixture" && req.method === "POST") { if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); + return Response.json( + { message: "No PG mock configured" }, + { status: 503, headers: { "Content-Type": "application/json" } }, + ); } - const { key } = (await req.json()) as { key: string }; - const fixturePath = join(ctx.fixturesDir, "pg", `${key}.json`); - let fixture: unknown; - try { - fixture = await Bun.file(fixturePath).json(); - } catch { - return new Response(JSON.stringify({ message: `PG fixture not found: ${key}` }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + const { key } = (yield* readJson(req)) as { key: string }; + const fixturePath = path.join(ctx.fixturesDir, "pg", `${key}.json`); + const fixture = yield* Effect.tryPromise(() => Bun.file(fixturePath).json()).pipe( + Effect.option, + ); + if (Option.isNone(fixture)) { + return Response.json( + { message: `PG fixture not found: ${key}` }, + { status: 404, headers: { "Content-Type": "application/json" } }, + ); } - ctx.pgMock.setState({ type: "fixture", fixture: fixture as PgFixture }); + ctx.pgMock.setState({ type: "fixture", fixture: fixture.value as PgFixture }); return new Response(null, { status: 204 }); } if (subpath === "/pg-error" && req.method === "POST") { if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); + return Response.json( + { message: "No PG mock configured" }, + { status: 503, headers: { "Content-Type": "application/json" } }, + ); } - const error = (await req.json()) as { code: string; message: string; severity?: string }; + const error = (yield* readJson(req)) as { code: string; message: string; severity?: string }; ctx.pgMock.setState({ type: "error", error }); return new Response(null, { status: 204 }); } return new Response("Not Found", { status: 404 }); -} +}); diff --git a/apps/cli-e2e/src/server/request-matcher.ts b/apps/cli-e2e/src/server/request-matcher.ts index b77e02e0fd..7f75ca9fd5 100644 --- a/apps/cli-e2e/src/server/request-matcher.ts +++ b/apps/cli-e2e/src/server/request-matcher.ts @@ -86,7 +86,7 @@ export function resetCounters(counters: SequenceCounters): void { /** Sorts array bodies canonically so comparison is order-insensitive where * element order isn't semantically meaningful (e.g. bulk-create secrets) — a - * proxied command building a request body from a Go map can produce a + * proxied command building a request body from a map can produce a * different key order per platform. Nested object fields are still compared * exactly; only the top-level array order is normalized. */ export function sortBody(body: unknown): unknown { diff --git a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts index 052284a2f0..eecc95b949 100644 --- a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts +++ b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts @@ -1,1361 +1,1771 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { BACKUP_TIMESTAMP, PROJECT_REF, SNIPPET_ID, isRecording } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("postgres-config", () => { describe("postgres-config:get", () => { - testBehaviour("renders config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders config overrides", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns config overrides as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).not.toBeNull(); - expect(typeof parsed).toBe("object"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).not.toBeNull(); + expect(typeof parsed).toBe("object"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("postgres-config:update", () => { - testBehaviour("sets single config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("sets multiple config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--config", - "shared_buffers=256MB", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("sets single config override", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("sets multiple config overrides", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--config", + "shared_buffers=256MB", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); testBehaviour( "--replace-existing-overrides replaces all overrides", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--replace-existing-overrides", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--replace-existing-overrides", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), ); testBehaviour( "--no-restart applies config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--no-restart", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 422 unrecognized config key", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "unrecognized config key: invalid_key" }, - }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "invalid_key=value", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("unrecognized config key"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--no-restart", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 unrecognized config key", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "unrecognized config key: invalid_key" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "invalid_key=value", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("unrecognized config key"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("postgres-config:delete", () => { - testBehaviour("removes config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple config keys", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--config", - "shared_buffers", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("removes config override", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("removes multiple config keys", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--config", + "shared_buffers", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); testBehaviour( "--no-restart removes config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--no-restart", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--no-restart", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("vanity-subdomains", () => { describe("vanity-subdomains:get", () => { - testBehaviour("renders vanity subdomain and status", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders vanity subdomain and status", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns subdomain config as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("status"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("status"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("vanity-subdomains:check-availability", () => { - testBehaviour("reports availability for desired subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("reports availability for desired subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); testBehaviour( "returns availability as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("available"); - }, - ); - - testBehaviour("exits non-zero without --desired-subdomain flag", async ({ run }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 subdomain taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "taken", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("available"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without --desired-subdomain flag", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 subdomain taken", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Subdomain already taken" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "taken", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); }); describe("vanity-subdomains:activate", () => { - testBehaviour("activates desired vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Activated vanity subdomain"); - }); - - testBehaviour("exits non-zero on 409 subdomain already taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "taken", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("activates desired vanity subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Activated vanity subdomain"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 subdomain already taken", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Subdomain already taken" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "taken", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("vanity-subdomains:delete", () => { - testBehaviour("removes vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted vanity subdomain"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("removes vanity subdomain", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted vanity subdomain"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "vanity-subdomains", + "delete", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("encryption", () => { describe("encryption:get-root-key", () => { - testBehaviour("renders root encryption key", async ({ run, projectRef }) => { - const result = await run(["encryption", "get-root-key", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); - - testBehaviour("returns root_key as JSON with --output json", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders root encryption key", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["encryption", "get-root-key", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); + + testBehaviour("returns root_key as JSON with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "get-root-key", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "get-root-key", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("encryption:update-root-key", () => { - testBehaviour.skipIf(isRecording)("rotates the vault root key", async ({ run, projectRef }) => { - const result = await run(["encryption", "update-root-key", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("rotates the vault root key", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("backups", () => { describe("backups:list", () => { - testBehaviour("renders backup table with REGION column", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REGION"); - }); + testBehaviour("renders backup table with REGION column", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["backups", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REGION"); + }), + signal, + ), + ); testBehaviour( "returns backup response as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "backups", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("region"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "backups", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toHaveProperty("region"); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["backups", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["backups", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("backups:restore", () => { testBehaviour.skipIf(isRecording)( "initiates PITR restore with -t timestamp", - async ({ run, projectRef }) => { - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 422 out-of-range timestamp", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "recovery time target is out of range" }, - }), - }); - const result = await run(["backups", "restore", "-t", "0", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("out of range"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 out-of-range timestamp", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "recovery time target is out of range" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + "0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("out of range"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("snippets", () => { describe("snippets:list", () => { - testBehaviour("renders snippet table with ID and NAME columns", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toContain("NAME"); - }); + testBehaviour("renders snippet table with ID and NAME columns", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["snippets", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toContain("NAME"); + }), + signal, + ), + ); testBehaviour( "returns snippet list as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "snippets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed.data)).toBe(true); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "snippets", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(Array.isArray(parsed.data)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["snippets", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["snippets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("snippets:download", () => { testBehaviour.skipIf(isRecording)( "prints SQL content to stdout", - async ({ run, projectRef }) => { - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }, - ); - - testBehaviour("exits non-zero on 404 snippet not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Snippet not found" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 snippet not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Snippet not found" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "snippets", + "download", + SNIPPET_ID, + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/auth.e2e.test.ts b/apps/cli-e2e/src/tests/auth.e2e.test.ts index d2ea7ae761..6172a265e4 100644 --- a/apps/cli-e2e/src/tests/auth.e2e.test.ts +++ b/apps/cli-e2e/src/tests/auth.e2e.test.ts @@ -5,5 +5,5 @@ import { describe, it } from "vitest"; // keychain dialog that blocks the subprocess indefinitely. describe("auth", () => { - it.todo("login and logout tests blocked on Go CLI keyring opt-out (see file comment)"); + it.todo("login and logout tests blocked on keyring opt-out (see file comment)"); }); diff --git a/apps/cli-e2e/src/tests/branches.e2e.test.ts b/apps/cli-e2e/src/tests/branches.e2e.test.ts index a956c1b577..5d317f4308 100644 --- a/apps/cli-e2e/src/tests/branches.e2e.test.ts +++ b/apps/cli-e2e/src/tests/branches.e2e.test.ts @@ -1,418 +1,642 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const BRANCH_NAME = "my-branch"; describe("branches", () => { describe("branches:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }), + signal, + ), + ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }, - ); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["branches", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("branches:create", () => { - testBehaviour.skipIf(isRecording)("creates ephemeral branch", async ({ run, projectRef }) => { - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates persistent branch", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--persistent", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates branch with data", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--with-data", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Branch name already in use" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch name already in use"); - }); - - testBehaviour("exits non-zero on 422 branching not enabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Preview branching is not enabled" }, + testBehaviour.skipIf(isRecording)("creates ephemeral branch", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("creates persistent branch", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--persistent", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("creates branch with data", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--with-data", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Branch name already in use" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch name already in use"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 branching not enabled", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Preview branching is not enabled" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Preview branching is not enabled"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid region", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid region" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--region", + "invalid-region", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--region"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Preview branching is not enabled"); - }); - - testBehaviour("exits non-zero on 422 invalid region", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid region" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--region", - "invalid-region", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--region"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + signal, + ), + ); }); describe("branches:get", () => { testBehaviour.skipIf(isRecording)( "returns single branch details", - async ({ run, projectRef }) => { - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(BRANCH_NAME); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "get", BRANCH_NAME, `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(BRANCH_NAME); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "get", - BRANCH_NAME, - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as Record; - expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "get", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "get", + BRANCH_NAME, + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run(["branches", "get", "nonexistent", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "get", BRANCH_NAME, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:update", () => { - testBehaviour.skipIf(isRecording)("renames branch with --name", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "renamed-branch", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }); + testBehaviour.skipIf(isRecording)("renames branch with --name", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "renamed-branch", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }), + signal, + ), + ); testBehaviour.skipIf(isRecording)( "changes git branch with --git-branch", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--git-branch", - "feature/new", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run([ - "branches", - "update", - "nonexistent", - "--name", - "new-name", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "new-name", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--git-branch", + "feature/new", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "update", + "nonexistent", + "--name", + "new-name", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "new-name", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:pause", () => { - testBehaviour.skipIf(isRecording)("pauses branch successfully", async ({ run, projectRef }) => { - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "pause", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("pauses branch successfully", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "pause", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:unpause", () => { testBehaviour.skipIf(isRecording)( "unpauses branch successfully", - async ({ run, projectRef }) => { - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:delete", () => { testBehaviour.skipIf(isRecording)( "deletes branch successfully", - async ({ run, projectRef }) => { - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "delete", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted preview branch:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Branch not found" }, + }); + const result = yield* run([ + "branches", + "delete", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); describe("branches:disable", () => { - testBehaviour.skipIf(isRecording)("disables preview branching", async ({ run, projectRef }) => { - const result = await run(["branches", "disable", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Disabled preview branching for project:"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "disable", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("disables preview branching", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["branches", "disable", "--yes", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Disabled preview branching for project:"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["branches", "disable", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index 617c6158cc..10604b33bf 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -1,18 +1,19 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; /** * Writes a supabase/config.toml covering every section `config push` * reconciles, each with a small diff against the recorded test project — a * section without a diff sends no PATCH/POST to the management API. */ -function writeConfigToml(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const writeConfigToml = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ` project_id = "test-project" @@ -53,150 +54,179 @@ file_size_limit = "50MiB" enabled = true `.trimStart(), ); -} +}); // `--yes` skips the per-section y/n prompt, since the test process runs with stdin closed. describe("config push", () => { - testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("emits HTTP trace with --debug", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF, "--debug"]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401 with token guidance", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Invalid token" }, + testBehaviour("reconciles every section against the remote", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("emits HTTP trace with --debug", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* run([ + "config", + "push", + "--yes", + "--debug", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); }), - }); + signal, + ), + ); - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); + testBehaviour("exits non-zero on 401 with token guidance", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); + }), + signal, + ), + ); testBehaviour( "exits non-zero on 403 with resource context", - async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { - message: `Forbidden: you do not have access to project ${PROJECT_REF}`, - }, + ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { + message: `Forbidden: you do not have access to project ${PROJECT_REF}`, + }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); }), - }); + signal, + ), + ); + + testBehaviour("exits non-zero on 404", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("404"); + }), + signal, + ), + ); - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }, + testBehaviour("exits non-zero on 409", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Conflict" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("409"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 404, - body: { message: "Project not found" }, + testBehaviour("exits non-zero on 422 with field detail", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { + message: "Invalid config: max_rows must be a positive integer", + }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("422"); + expect(result.stderr).toContain("max_rows"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("404"); - }); - - testBehaviour("exits non-zero on 409", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Conflict" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("409"); - }); - - testBehaviour("exits non-zero on 422 with field detail", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Invalid config: max_rows must be a positive integer", - }, + signal, + ), + ); + + testBehaviour("exits non-zero on 429 after retrying", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("422"); - expect(result.stderr).toContain("max_rows"); - }); - - testBehaviour("exits non-zero on 429 after retrying", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, + signal, + ), + ); + + testBehaviour("exits non-zero on 502", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 502, + body: { message: "Bad Gateway" }, + }); + + yield* writeConfigToml(workspace.path); + const result = yield* run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); - - testBehaviour("exits non-zero on 502", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 502, body: { message: "Bad Gateway" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index e21e572773..121dc418aa 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -1,8 +1,7 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; // Regression guard: a non-string field written as `env(VAR)` (e.g. // a port) must not crash config decoding before env resolution runs. @@ -10,10 +9,12 @@ import { testBehaviour } from "./test-context.ts"; // A 401 is injected so the test doesn't need a real API fixture; it only // asserts that the CLI got past config decode. -function writeConfigWithEnvPorts(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const writeConfigWithEnvPorts = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), [ 'project_id = "with-env-ports"', "", @@ -28,7 +29,7 @@ function writeConfigWithEnvPorts(dir: string): void { "", ].join("\n"), ); -} +}); const ENV_PORTS = { SUPABASE_API_PORT: "54321", @@ -37,21 +38,25 @@ const ENV_PORTS = { }; describe("env-in-config-toml", () => { - testBehaviour("does not crash on numeric fields", async ({ run, workspace, apiUrl }) => { - writeConfigWithEnvPorts(workspace.path); + testBehaviour("does not crash on numeric fields", ({ run, workspace, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* writeConfigWithEnvPorts(workspace.path); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF], { - env: ENV_PORTS, - }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`], { + env: ENV_PORTS, + }); - const output = `${result.stdout}\n${result.stderr}`; - expect(output).not.toContain("CliConfigParseError"); - expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); - }); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).not.toContain("CliConfigParseError"); + expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 0adcb9b5ad..11c385925c 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -1,179 +1,229 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; - -/** Writes .supabase/.temp/project-ref and a stub pooler-url so --linked - * commands resolve a connection without a live postgres TCP probe (nothing - * listens on db.{ref}.localhost:5432 in the test harness); combined with - * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler - * and makes the Management API call under test. */ -function linkProject(dir: string, ref: string): void { - const tempDir = join(dir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), ref); - writeFileSync( - join(tempDir, "pooler-url"), - `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, - ); -} +import { linkProject } from "./link-project.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const TEST_MIGRATION_SQL = "CREATE TABLE IF NOT EXISTS e2e_test_table (id bigint generated always as identity primary key);"; /** Create a single migration file in supabase/migrations/ and return the SQL. */ -function seedMigration(dir: string): void { - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_e2e_test.sql"), TEST_MIGRATION_SQL); -} +const seedMigration = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_e2e_test.sql"), + TEST_MIGRATION_SQL, + ); +}); describe("db advisors", () => { describe("db advisors:security", () => { - testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("No issues found"); - }); + testBehaviour("returns security advisors", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("No issues found"); + }), + signal, + ), + ); testBehaviour( "exits zero when --fail-on error and no error-level advisors found", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run([ - "db", - "advisors", - "--linked", - "--type", - "security", - "--fail-on", - "error", - ]); - expect(result.exitCode).toBe(0); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "advisors", + "--type", + "security", + "--fail-on", + "error", + "--remote", + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["db", "advisors", "--type", "security", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("db advisors:performance", () => { - testBehaviour("returns performance advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "performance"]); - expect(result.exitCode).toBe(0); - if (result.stdout.trim()) { - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - } else { - expect(result.stderr).toContain("No issues found"); - } - }); + testBehaviour("returns performance advisors", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "performance", "--remote"]); + expect(result.exitCode).toBe(0); + if (result.stdout.trim()) { + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + } else { + expect(result.stderr).toContain("No issues found"); + } + }), + signal, + ), + ); }); describe("db advisors:all", () => { - testBehaviour("returns advisors with --type all", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "all"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("returns advisors with --type all", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "advisors", "--type", "all", "--remote"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); }); describe("db query", () => { - describe("db query:linked", () => { + describe("db query:remote", () => { testBehaviour( "returns SELECT 1 result in table format", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("1"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }), + signal, + ), ); - testBehaviour("returns JSON with --output json", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "--output", "json", "SELECT 1"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown; - // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. - // In normal mode it's a plain array. - const rows = Array.isArray(parsed) ? parsed : (parsed as { rows: unknown[] }).rows; - expect(Array.isArray(rows)).toBe(true); - expect(rows).toHaveLength(1); - }); + testBehaviour("returns JSON with --output json", ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "query", + "--output", + "json", + "SELECT 1", + "--read-only", + "--remote", + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. + // In normal mode it's a plain array. + const rows = Array.isArray(parsed) ? parsed : (parsed as { rows: unknown[] }).rows; + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(1); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["db", "query", "SELECT 1", "--read-only", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); }); }); @@ -181,96 +231,151 @@ describe("db push", () => { describe("db push:dry-run", () => { testBehaviour( "exits non-zero on connection refused with --dry-run", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - seedMigration(workspace.path); - const result = await run(["db", "push", "--dry-run"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* seedMigration(workspace.path); + const result = yield* run(["db", "push", "--dry-run", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), ); }); describe("db push:local", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "push", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "push"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); - describe("db push:linked", () => { + describe("db push:remote", () => { testBehaviour( - "exits non-zero on connection refused with --linked", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "push"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + "exits non-zero on connection refused with --remote", + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run(["db", "push", "--remote"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), ); }); }); describe("db pull", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "pull", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "pull"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); describe("db lint", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "lint", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "lint"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); describe("db dump", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "dump", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "dump"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); - testBehaviour("exits non-zero when --role-only and --data-only are both set", async ({ run }) => { - const result = await run(["db", "dump", "--local", "--role-only", "--data-only"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); - }); + testBehaviour("exits non-zero when --role-only and --data-only are both set", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "dump", "--role-only", "--data-only"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); + }), + signal, + ), + ); }); describe("db reset", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "reset", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/connect|not running/i); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["db", "reset"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/connect|not running/i); + }), + signal, + ), + ); }); describe("test new", () => { - testBehaviour("creates a pgTAP test file", async ({ run, workspace }) => { - const result = await run(["test", "new", "my_test"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/my_test/); - const files = readdirSync(join(workspace.path, "supabase", "tests")).filter((f) => - f.endsWith("my_test_test.sql"), - ); - expect(files.length).toBe(1); - }); + testBehaviour("creates a pgTAP test file", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["test", "new", "my_test"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/my_test/); + const files = (yield* fs.readDirectory( + path.join(workspace.path, "supabase", "tests"), + )).filter((f) => f.endsWith("my_test_test.sql")); + expect(files.length).toBe(1); + }), + signal, + ), + ); - testBehaviour("exits non-zero when name argument is missing", async ({ run }) => { - const result = await run(["test", "new"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero when name argument is missing", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["test", "new"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("test db", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["test", "db", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused without --remote", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["test", "db"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index 72cb5f37bc..30cc4b6384 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -1,6 +1,7 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; -import { testBehaviour } from "./test-context"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context"; import { isRecording, PROJECT_REF } from "./env"; const CONFIGURED_CNAME = "www.urgsimurksi.xyz"; @@ -9,259 +10,355 @@ describe("domains", () => { describe.todo("domains:create — requires mocking of 1.1.1.1 for DNS queries"); describe("domains:get", () => { - testBehaviour("custom domain disabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Please enable custom domains first" }, + testBehaviour("custom domain disabled", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 400, + body: { message: "Please enable custom domains first" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, - ); - }); - - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run([ - "domains", - "get", - "--project-ref", - PROJECT_REF, - "--output", - "json", - ]); - - expect(result.exitCode).toBe(0); - - expect(JSON.parse(result.stdout)).toEqual({ - custom_hostname: "www.urgsimurksi.xyz", - data: { - errors: [], - messages: [], - result: { - custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, - hostname: "www.urgsimurksi.xyz", - id: "00000000-0000-0000-0000-000000000000", - ownership_verification: { - name: "", - type: "", - value: "", - }, - ssl: { - status: "active", - validation_records: [], + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, + ); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("domain activated", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "domains", + "get", + "--output", + "json", + `--remote=${PROJECT_REF}`, + ]); + + expect(result.exitCode).toBe(0); + + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toEqual({ + custom_hostname: "www.urgsimurksi.xyz", + data: { + errors: [], + messages: [], + result: { + custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, + hostname: "www.urgsimurksi.xyz", + id: "00000000-0000-0000-0000-000000000000", + ownership_verification: { + name: "", + type: "", + value: "", + }, + ssl: { + status: "active", + validation_records: [], + }, + status: "active", + }, + success: true, }, - status: "active", - }, - success: true, - }, - status: "5_services_reconfigured", - }); - }); - - testBehaviour("exists non-zero on 403", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { message: "Unauthorized" }, + status: "5_services_reconfigured", + }); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }); - - testBehaviour("exists non-zero on 401", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Unauthorized" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 403", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Unauthorized" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); - - testBehaviour("exists non-zero on 429", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 401", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Unauthorized" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); - - testBehaviour("exists non-zero on 500", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 429", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); - - testBehaviour("exists non-zero on 502", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 502, - body: { message: "Bad Gateway" }, + signal, + ), + ); + + testBehaviour("exists non-zero on 500", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + signal, + ), + ); + + testBehaviour("exists non-zero on 502", ({ apiUrl, run, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 502, + body: { message: "Bad Gateway" }, + }); + + const result = yield* run(["domains", "get", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); + }), + signal, + ), + ); }); describe("domains:reverify", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "reverify", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); }); describe("domains:activate", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("pending verification in debug mode", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF, "--debug"]); + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/HTTP.*POST:/); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain(`completed`); - expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification in debug mode", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "domains", + "activate", + "--debug", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/HTTP.*POST:/); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`completed`); + expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("domain activated", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); }); describe("domains:delete", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + testBehaviour("custom domain disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("no custom domain", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }), + signal, + ), + ); - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + testBehaviour.skipIf(isRecording)("pending verification", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); - expect(result.exitCode).toBe(0); - }); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 9bd1c8f850..29e211d96b 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,64 +1,60 @@ +import { Config, ConfigProvider, Effect, Option } from "effect"; import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; -type CliE2eMode = "replay" | "record"; +/** Reads an environment variable through `Config`, keeping empty values as set. */ +const readEnv = (name: string): string | undefined => + Effect.runSync( + Config.option(Config.String(name)) + .parse(ConfigProvider.fromEnv({ preserveEmptyStrings: true })) + .pipe(Effect.map(Option.getOrUndefined)), + ); // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to // staging and captures fixtures. // Back-compat: RECORD=true still maps to `record`. -const MODE: CliE2eMode = - (process.env["CLI_E2E_MODE"] as CliE2eMode | undefined) ?? - (process.env["RECORD"] === "true" ? "record" : "replay"); +const MODE = readEnv("CLI_E2E_MODE") ?? (readEnv("RECORD") === "true" ? "record" : "replay"); export const isRecording = MODE === "record"; -// The replay server + tests/setup.ts key recording off RECORD directly, so keep -// it in sync with MODE in both directions: an explicit CLI_E2E_MODE=replay must -// not record and wipe fixtures just because RECORD=true lingers in the shell. -if (isRecording) { - process.env["RECORD"] = "true"; -} else { - delete process.env["RECORD"]; -} - -// startReplayServer + tests/setup.ts read SUPABASE_STAGING_URL directly as the -// record proxy target. Normalise it from CLI_E2E_API_URL so +// The record proxy target. Normalised from CLI_E2E_API_URL so // `CLI_E2E_MODE=record CLI_E2E_API_URL=…` works without also setting the legacy var. -if (isRecording && !process.env["SUPABASE_STAGING_URL"] && process.env["CLI_E2E_API_URL"]) { - process.env["SUPABASE_STAGING_URL"] = process.env["CLI_E2E_API_URL"]; -} +const LEGACY_STAGING_URL = readEnv("SUPABASE_STAGING_URL"); +const API_URL = readEnv("CLI_E2E_API_URL"); +export const STAGING_URL = + isRecording && !LEGACY_STAGING_URL && API_URL ? API_URL : LEGACY_STAGING_URL; // In replay mode the token never reaches a real API, but the CLI validates the // format before making any request (must match sbp_[a-f0-9]{40}). In record mode // it must be a valid token for the staging API. export const ACCESS_TOKEN = - process.env["SUPABASE_ACCESS_TOKEN"] ?? "sbp_0000000000000000000000000000000000000000"; + readEnv("SUPABASE_ACCESS_TOKEN") ?? "sbp_0000000000000000000000000000000000000000"; // Region for the fresh recording project. -export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; +export const REGION = readEnv("CLI_E2E_REGION") ?? "us-east-1"; // Recorded fixtures store every project ref and org slug as REPLAY_PROJECT_REF, so replay // requests use it too. In record mode supply a real project ref via env. -export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? REPLAY_PROJECT_REF; +export const PROJECT_REF = readEnv("SUPABASE_TEST_PROJECT_REF") ?? REPLAY_PROJECT_REF; // In record mode supply a real org slug via env, or let the resolver derive it. -export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? REPLAY_PROJECT_REF; +export const ORG_ID = readEnv("SUPABASE_TEST_ORG_ID") ?? REPLAY_PROJECT_REF; // UUID of an existing SAML provider on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real provider ID via env. export const PROVIDER_ID = - process.env["SUPABASE_TEST_PROVIDER_ID"] ?? "00000000-0000-0000-0000-000000000000"; + readEnv("SUPABASE_TEST_PROVIDER_ID") ?? "00000000-0000-0000-0000-000000000000"; // UUID of an existing SQL snippet on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real snippet UUID via env. export const SNIPPET_ID = - process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-4000-8000-000000000001"; + readEnv("SUPABASE_TEST_SNIPPET_ID") ?? "00000000-0000-4000-8000-000000000001"; // Unix epoch seconds for a PITR restore timestamp within the staging project's backup window. // In replay mode the replay server serves responses in order regardless of the request body value. // In record mode supply a real timestamp (within the backup window) via env. export const BACKUP_TIMESTAMP = parseInt( - process.env["SUPABASE_TEST_BACKUP_TIMESTAMP"] ?? "1707407047", + readEnv("SUPABASE_TEST_BACKUP_TIMESTAMP") ?? "1707407047", 10, ); diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index f39a423806..fc2d40390d 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -1,363 +1,453 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const FUNCTION_NAME = "hello-world"; describe("functions", () => { describe("functions:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "functions", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["functions", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:deploy", () => { - // Deploy requires the Go binary to bundle function files locally before any API call, - // so error injection tests pre-create the function with `functions new` first. + // Deploy bundles function files locally before any API call, so error injection tests + // pre-create the function with `functions new` first. - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/rate-limit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: `/v1/projects/${PROJECT_REF}/functions/deploy`, - retryAfterSeconds: 0, + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/rate-limit`, { + path: `/v1/projects/${PROJECT_REF}/functions/deploy`, + retryAfterSeconds: 0, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["functions", "new", FUNCTION_NAME]); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "deploy", + FUNCTION_NAME, + "--use-api", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:delete", () => { testBehaviour.skipIf(isRecording)( "deletes function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted Function"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("does not exist"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Function not found" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("does not exist"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "delete", + FUNCTION_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:download", () => { testBehaviour.skipIf(isRecording)( "downloads function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Downloaded Function"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Downloaded Function"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Function not found"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Function not found" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Function not found"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("functions:new", () => { - testBehaviour("successfully creates a new function", async ({ run }) => { - const result = await run(["functions", "new", "testFunction"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new Function at supabase/functions/testFunction"); - }); + testBehaviour("successfully creates a new function", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["functions", "new", "testFunction"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Created new Function at supabase/functions/testFunction", + ); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index 5362a5bf12..f6a1bd7bc3 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -1,6 +1,7 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; function decodeJwtPart(part: string): Record { const padded = part + "=".repeat((4 - (part.length % 4)) % 4); @@ -11,142 +12,204 @@ describe("gen", () => { describe("gen:types", () => { testBehaviour.skipIf(isRecording)( "generates typescript types from project", - async ({ run, projectRef }) => { - // #5212 — with CLAUDECODE=1, piped/redirected output must not include the - // plugin hint (would break `gen types > file.ts` and similar captures). - const result = await run(["gen", "types", "--project-id", projectRef], { - env: { CLAUDECODE: "1" }, - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("export type Json"); - expect(result.stdout).toContain("export type Database"); - expect(result.stdout).not.toMatch(/claude-code-hint/); - expect(result.stderr).not.toMatch(/claude-code-hint/); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + // #5212 — with CLAUDECODE=1, piped/redirected output must not include the + // plugin hint (would break `gen types > file.ts` and similar captures). + const result = yield* run(["gen", "types", `--remote=${projectRef}`], { + env: { CLAUDECODE: "1" }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("export type Json"); + expect(result.stdout).toContain("export type Database"); + expect(result.stdout).not.toMatch(/claude-code-hint/); + expect(result.stderr).not.toMatch(/claude-code-hint/); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "includes debug output with --debug", - async ({ run, projectRef }) => { - const result = await run(["gen", "types", "--debug", "--project-id", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "types", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["gen", "types", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); - testBehaviour("exits non-zero with no data source specified", async ({ runNoProjectId }) => { - const result = await runNoProjectId(["gen", "types"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour( + "exits non-zero when the local stack is not running", + ({ runNoProjectId, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* runNoProjectId(["gen", "types"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Target: local stack"); + expect(result.stderr).toContain("supabase start is not running"); + }), + signal, + ), + ); }); describe("gen:signing-key", () => { - testBehaviour("generates ES256 signing key by default", async ({ run }) => { - const result = await run(["gen", "signing-key"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("EC"); - expect(key["alg"]).toBe("ES256"); - expect(key["crv"]).toBe("P-256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["d"]).toBe("string"); - expect((key["d"] as string).length).toBeGreaterThan(0); - }); + testBehaviour("generates ES256 signing key by default", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "signing-key"]); + expect(result.exitCode).toBe(0); + const key = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(key["kty"]).toBe("EC"); + expect(key["alg"]).toBe("ES256"); + expect(key["crv"]).toBe("P-256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["d"]).toBe("string"); + expect((key["d"] as string).length).toBeGreaterThan(0); + }), + signal, + ), + ); - testBehaviour("generates RS256 signing key with --algorithm RS256", async ({ run }) => { - const result = await run(["gen", "signing-key", "--algorithm", "RS256"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("RSA"); - expect(key["alg"]).toBe("RS256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["n"]).toBe("string"); - expect((key["n"] as string).length).toBeGreaterThan(0); - }); + testBehaviour("generates RS256 signing key with --algorithm RS256", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "signing-key", "--algorithm", "RS256"]); + expect(result.exitCode).toBe(0); + const key = yield* decodeJsonOutput(Schema.Record(Schema.String, Schema.Unknown))( + result.stdout, + ); + expect(key["kty"]).toBe("RSA"); + expect(key["alg"]).toBe("RS256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["n"]).toBe("string"); + expect((key["n"] as string).length).toBeGreaterThan(0); + }), + signal, + ), + ); }); describe("gen:bearer-jwt", () => { - testBehaviour("exits non-zero without --role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"role"'); - }); + testBehaviour("exits non-zero without --role", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"role"'); + }), + signal, + ), + ); - testBehaviour("generates bearer jwt for anon role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const header = decodeJwtPart(parts[0]!); - expect(header["alg"]).toBe("ES256"); - expect(typeof header["kid"]).toBe("string"); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect(typeof payload["exp"]).toBe("number"); - expect(typeof payload["iat"]).toBe("number"); - }); + testBehaviour("generates bearer jwt for anon role", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt", "--role", "anon"]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const header = decodeJwtPart(parts[0]!); + expect(header["alg"]).toBe("ES256"); + expect(typeof header["kid"]).toBe("string"); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect(typeof payload["exp"]).toBe("number"); + expect(typeof payload["iat"]).toBe("number"); + }), + signal, + ), + ); - testBehaviour("generates bearer jwt with custom validity", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); - }); + testBehaviour("generates bearer jwt with custom validity", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); + }), + signal, + ), + ); testBehaviour( "generates bearer jwt for authenticated role with custom sub", - async ({ run }) => { - const result = await run([ - "gen", - "bearer-jwt", - "--role", - "authenticated", - "--sub", - "user-123", - ]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("authenticated"); - expect(payload["sub"]).toBe("user-123"); - }, + ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "gen", + "bearer-jwt", + "--role", + "authenticated", + "--sub", + "user-123", + ]); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("authenticated"); + expect(payload["sub"]).toBe("user-123"); + }), + signal, + ), ); }); }); diff --git a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts deleted file mode 100644 index fc0b9acfe0..0000000000 --- a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; - -// The bundled `supabase-go` binary retains only the commands the TypeScript -// CLI's `GoProxy` can spawn; every other Go command was deleted. TS -// integration tests stub that subprocess boundary, so nothing else notices if -// a still-reachable Go command gets trimmed away by mistake. -// -// This suite enumerates every argv shape the TS side can hand to `GoProxy` -// and asserts the built `supabase-go` binary still resolves it. If this -// fails, either the TS spawn surface grew (add the command to the retained -// set in `apps/cli-go`) or the trim cut too deep (restore the command). -// -// `SUPABASE_GO_BINARY` is only set to a freshly built binary in CI (see -// `.github/workflows/test.yml`); locally this whole suite no-ops. -const GO_BINARY = process.env["SUPABASE_GO_BINARY"]; - -describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", () => { - const binary = GO_BINARY as string; - - let workspaceDir: string; - let bogusProfilePath: string; - - beforeAll(() => { - workspaceDir = mkdtempSync(join(tmpdir(), "cli-e2e-go-binary-surface-")); - - // The upgrade check hits the real GitHub releases API on every invocation - // that returns a nil error (e.g. every `--help` call below), unless a - // `supabase/.temp/cli-latest` cache file already exists and is less than - // 10h old. Pre-seeding it here keeps this suite hermetic. - mkdirSync(join(workspaceDir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workspaceDir, "supabase", ".temp", "cli-latest"), "v0.0.0"); - - // A bogus `--profile` for the two Management-API-gated delegates (`gen - // keys`, `functions download --legacy-bundle`): the unique profile name - // can never match a real stored credential, and the unreachable api_url - // means even a stray match still fails at connect. - bogusProfilePath = join(workspaceDir, "profile.yaml"); - writeFileSync( - bogusProfilePath, - [ - "name: cli-e2e-go-binary-surface-guard", - 'api_url: "http://127.0.0.1:1"', - 'dashboard_url: "http://127.0.0.1:1"', - "project_host: localhost", - ].join("\n"), - ); - }); - - afterAll(() => { - rmSync(workspaceDir, { recursive: true, force: true }); - }); - - function runGo(args: ReadonlyArray, envOverrides: Record = {}) { - const result = Bun.spawnSync([binary, ...args], { - cwd: workspaceDir, - env: { - PATH: process.env["PATH"] ?? "", - HOME: workspaceDir, - SUPABASE_HOME: workspaceDir, - // Belt-and-braces: no command exercised here should ever reach a - // real Docker daemon or send real telemetry. - DOCKER_HOST: "tcp://127.0.0.1:1", - SUPABASE_TELEMETRY_DISABLED: "1", - ...envOverrides, - }, - stdout: "pipe", - stderr: "pipe", - }); - return { - exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - }; - } - - // The complete spawn surface, mirrored from `GoProxy` call sites: - // - db diff (diff.handler.ts, `--use-pg-schema` delegate path) - // - db branch create|delete|list|switch (thin proxies) - // - db remote changes (thin proxy) - // - gen keys (keys.handler.ts) - // - functions download (shared/functions/download.ts, `--legacy-bundle`) - const RETAINED_COMMAND_PATHS: ReadonlyArray> = [ - ["db", "diff"], - ["db", "branch", "create"], - ["db", "branch", "delete"], - ["db", "branch", "list"], - ["db", "branch", "switch"], - ["db", "remote", "changes"], - ["gen", "keys"], - ["functions", "download"], - ]; - - describe("resolves every retained command path", () => { - for (const path of RETAINED_COMMAND_PATHS) { - test(`supabase-go ${path.join(" ")} --help exits 0`, () => { - const { exitCode, stdout, stderr } = runGo([...path, "--help"]); - const output = stdout + stderr; - expect(exitCode).toBe(0); - expect(output).not.toContain("unknown command"); - // A deleted nested subcommand doesn't error "unknown command": cobra - // only rejects unmatched args on the root command, so `db diff --help` - // with `diff` deleted would fall through to `db`'s own help (exit 0). - // Asserting the full command path appears in the usage output closes - // that gap — cobra only prints it once the whole path has resolved. - expect(output).toContain(`supabase ${path.join(" ")}`); - }, 5_000); - } - }); - - describe("accepts the exact argv shape the TS proxy builds", () => { - // `db diff` always provisions a Docker shadow database first, so the bogus - // DOCKER_HOST is what makes this fail fast, not the bogus --db-url. - test("db diff --use-pg-schema", () => { - const { exitCode, stderr } = runGo([ - "db", - "diff", - "--use-pg-schema", - "--db-url", - "postgresql://u:p@127.0.0.1:1/x", - "--schema", - "public", - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - - // The only invocation in this suite exercising the full global-flag set at - // once. Also provisions a Docker shadow first, so DOCKER_HOST trips it. - test("db remote changes (full global flag set)", () => { - const { exitCode, stderr } = runGo([ - "--output", - "json", - "--profile", - "supabase-staging", - "--debug", - "--workdir", - workspaceDir, - "--experimental", - "--network-id", - "cli-e2e-test-net", - "--yes", - "--dns-resolver", - "https", - "--create-ticket", - "--agent", - "no", - "db", - "remote", - "changes", - "--db-url", - "postgresql://u:p@127.0.0.1:1/x", - "--schema", - "public", - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - - // Gated behind a login check before any network call, so an isolated - // SUPABASE_HOME plus the bogus --profile fails fast without reaching a real API. - test("gen keys", () => { - const { exitCode, stderr } = runGo([ - "gen", - "keys", - "--project-ref", - "abcdefghijklmnopqrst", - "--override-name", - "db.host=CUSTOM_DB_HOST", - "--experimental", - "--profile", - bogusProfilePath, - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - - // Same login-gate fail-fast as `gen keys`. - test("functions download --legacy-bundle", () => { - const { exitCode, stderr } = runGo([ - "functions", - "download", - "my-function", - "--project-ref", - "abcdefghijklmnopqrst", - "--legacy-bundle", - "--profile", - bogusProfilePath, - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - }); - - describe("negative control: a deleted command still reports unknown", () => { - // `["db", "start"]` isn't used here even though it's a deleted command: - // like the nested-subcommand case above, `db` would fall through to its - // own help (exit 0) instead of erroring. Only a deleted top-level command - // reliably reproduces "unknown command". - for (const deletedCommand of ["inspect", "start"]) { - test(`supabase-go ${deletedCommand} reports unknown command`, () => { - const { exitCode, stderr } = runGo([deletedCommand]); - expect(exitCode).toBe(1); - expect(stderr).toContain("unknown command"); - }, 5_000); - } - }); -}); diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index 9f5014dab1..12f3e4d9d2 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -1,24 +1,19 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; -function setupInspectWorkspace(dir: string, pgPort: number): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const setupInspectWorkspace = Effect.fnUntraced(function* (dir: string, pgPort: number) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ['project_id = "test-project"', "", "[db]", `port = ${pgPort}`].join("\n"), ); -} +}); -async function setPgFixture(apiUrl: string, key: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/pg-fixture`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!res.ok) throw new Error(`Failed to set PG fixture "${key}": ${await res.text()}`); -} +const setPgFixture = (apiUrl: string, key: string) => + postControl(`${apiUrl}/_ctrl/pg-fixture`, { key }); const SUBCOMMANDS = [ { name: "db-stats", fixtureKey: "db-stats", assertValue: "42 MB" }, @@ -37,70 +32,96 @@ const SUBCOMMANDS = [ ] as const; describe("inspect:flags", () => { - testBehaviour("rejects --db-url with --local", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const result = await run([ - "inspect", - "db", - "db-stats", - "--db-url", - "postgresql://postgres:postgres@localhost:5432/postgres", - "--local", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("rejects --remote with --local", ({ run, workspace, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const result = yield* run([ + "inspect", + "db", + "db-stats", + "--local", + "--remote=postgresql://postgres:postgres@localhost:5432/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + ); + }), + signal, + ), + ); }); for (const { name, fixtureKey, assertValue } of SUBCOMMANDS) { describe(`inspect:db:${name}`, () => { testBehaviour( "renders query results as a table", - async ({ run, workspace, apiUrl, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - await setPgFixture(apiUrl, fixtureKey); - const result = await run(["inspect", "db", name, "--local"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(assertValue); - }, + ({ run, workspace, apiUrl, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + yield* setPgFixture(apiUrl, fixtureKey); + const result = yield* run(["inspect", "db", name]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(assertValue); + }), + signal, + ), ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "db", - name, - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "inspect", + "db", + name, + "--remote=postgresql://postgres:postgres@localhost:1/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); } describe("inspect:report", () => { - testBehaviour("saves CSV files on success", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const outDir = join(workspace.path, "report-out"); - const result = await run(["inspect", "report", "--local", "--output-dir", outDir]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Reports saved to"); - const subdirs = readdirSync(outDir); - expect(subdirs.length).toBe(1); - const dateDir = subdirs[0]!; - const csvFiles = readdirSync(join(outDir, dateDir)); - expect(csvFiles.length).toBeGreaterThan(0); - expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); - }); + testBehaviour("saves CSV files on success", ({ run, workspace, pgMockPort, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const outDir = path.join(workspace.path, "report-out"); + const result = yield* run(["inspect", "report", "--output-dir", outDir]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Reports saved to"); + const subdirs = yield* fs.readDirectory(outDir); + expect(subdirs.length).toBe(1); + const dateDir = subdirs[0]!; + const csvFiles = yield* fs.readDirectory(path.join(outDir, dateDir)); + expect(csvFiles.length).toBeGreaterThan(0); + expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "report", - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "inspect", + "report", + "--remote=postgresql://postgres:postgres@localhost:1/postgres", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/link-project.ts b/apps/cli-e2e/src/tests/link-project.ts new file mode 100644 index 0000000000..c405530943 --- /dev/null +++ b/apps/cli-e2e/src/tests/link-project.ts @@ -0,0 +1,14 @@ +import { Effect, FileSystem, Path } from "effect"; + +/** Writes `project-ref` and a stub `pooler-url` so `--remote` skips the live Postgres probe. */ +export const linkProject = Effect.fnUntraced(function* (dir: string, ref: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), ref); + yield* fs.writeFileString( + path.join(tempDir, "pooler-url"), + `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, + ); +}); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 3a0c349e42..a18e0bb383 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -1,95 +1,155 @@ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; const MIGRATION_NAME = "my_change"; describe("migrations", () => { describe("migration:new", () => { - testBehaviour("creates timestamped sql file", async ({ run, workspace }) => { - const result = await run(["migration", "new", MIGRATION_NAME]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new migration at"); - const files = readdirSync(join(workspace.path, "supabase", "migrations")); - expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); - }); + testBehaviour("creates timestamped sql file", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["migration", "new", MIGRATION_NAME]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created new migration at"); + const files = yield* fs.readDirectory( + path.join(workspace.path, "supabase", "migrations"), + ); + expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits non-zero without name argument", async ({ run }) => { - const result = await run(["migration", "new"]); - expect(result.exitCode).not.toBe(0); - // A missing positional argument's usage block prints to stderr only. - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("migration name"); - }); + testBehaviour("exits non-zero without name argument", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "new"]); + expect(result.exitCode).not.toBe(0); + // A missing positional argument's usage block prints to stderr only. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); + }), + signal, + ), + ); }); describe("migration:list", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "list", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:up", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "up", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "up"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:down", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "down", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "down"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused with --last 2", async ({ run }) => { - const result = await run(["migration", "down", "--last", "2", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused with --last 2", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "down", "--last", "2"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:repair", () => { - testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { - const result = await run(["migration", "repair", "--local", "20230101000000"]); - expect(result.exitCode).not.toBe(0); - // A missing required flag's error prints once, without a duplicate - // usage dump, and spells the flag name without its `--` prefix. - expect(result.stderr).toContain('"status" not set'); - }); + testBehaviour("exits non-zero when --status flag is missing", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "repair", "20230101000000"]); + expect(result.exitCode).not.toBe(0); + // A missing required flag's error prints once, without a duplicate + // usage dump, and spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); + }), + signal, + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "migration", - "repair", - "--status", - "applied", - "--local", - "20230101000000", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(workspace.path, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20230101000000_x.sql"), + "select 1;\n", + ); + const result = yield* run([ + "migration", + "repair", + "--status", + "applied", + "20230101000000", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); describe("migration:squash", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "squash", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "squash"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + signal, + ), + ); }); describe("migration:fetch", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "fetch", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["migration", "fetch"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/network-security.e2e.test.ts b/apps/cli-e2e/src/tests/network-security.e2e.test.ts index 8cff53bcc4..8e80199da4 100644 --- a/apps/cli-e2e/src/tests/network-security.e2e.test.ts +++ b/apps/cli-e2e/src/tests/network-security.e2e.test.ts @@ -1,710 +1,883 @@ +import { Effect } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("network-bans", () => { describe("network-bans:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "get", + "--experimental", + "--debug", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-bans", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("network-bans:remove", () => { - testBehaviour("removes IP from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple IPs from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--db-unban-ip", - "5.6.7.8", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid IP address", async ({ run }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "invalid-ip", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("invalid IP address"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); + testBehaviour("removes IP from ban list", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("removes multiple IPs from ban list", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--db-unban-ip", + "5.6.7.8", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with invalid IP address", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "invalid-ip", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("invalid IP address"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); }); }); describe("network-restrictions", () => { describe("network-restrictions:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-restrictions", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("network-restrictions:update", () => { - testBehaviour("sets CIDR allowlist", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("appends to existing restrictions", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--append", - "--db-allow-cidr", - "8.8.8.0/24", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("bypasses CIDR validation checks", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--bypass-cidr-checks", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid CIDR format", async ({ run }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "not-a-cidr", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to parse IP"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("sets CIDR allowlist", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("appends to existing restrictions", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--append", + "--db-allow-cidr", + "8.8.8.0/24", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("bypasses CIDR validation checks", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--bypass-cidr-checks", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with invalid CIDR format", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "not-a-cidr", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to parse IP"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); describe("ssl-enforcement", () => { describe("ssl-enforcement:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "ssl-enforcement", + "get", + "--experimental", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("ssl-enforcement:update", () => { - testBehaviour("enables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("disables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--disable-db-ssl-enforcement", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with mutually exclusive flags", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--disable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero with no flags provided", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("enables SSL enforcement", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("disables SSL enforcement", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--disable-db-ssl-enforcement", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with mutually exclusive flags", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--disable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with no flags provided", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 028ab99aed..4f362f6507 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -1,157 +1,222 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const ORG_NAME = "My Test Org"; describe("orgs", () => { describe("orgs:list", () => { - testBehaviour("renders org data", async ({ run }) => { - const result = await run(["orgs", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toMatch(/[a-z]{20}/); - }); - - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["orgs", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); - }); - - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["orgs", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders org data", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toMatch(/[a-z]{20}/); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list", "--output", "json"]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + /* Narrows the type only: the assertion above already fails a non-array. */ + if (!Array.isArray(parsed)) return; + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "list", "--debug"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["orgs", "list"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("orgs:create", () => { - testBehaviour.skipIf(isRecording)("creates organization with name", async ({ run }) => { - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created organization:"); - expect(result.stdout).toMatch(/[a-z]{20}/); - }); - - testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { - const result = await run(["orgs", "create"]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Organization name already in use" }, + testBehaviour.skipIf(isRecording)("creates organization with name", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created organization:"); + expect(result.stdout).toMatch(/[a-z]{20}/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without name in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["orgs", "create", "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Organization name already in use" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization name already in use"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403 plan limit reached", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Organization limit reached" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization limit reached"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid organization name" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid organization name"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["orgs", "create", ORG_NAME, "--yes"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization name already in use"); - }); - - testBehaviour("exits non-zero on 403 plan limit reached", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Organization limit reached" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization limit reached"); - }); - - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid organization name" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid organization name"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index 44cc611b30..e3e3bf27df 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -1,63 +1,107 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, inject, test } from "vitest"; -import { createHarness, exec, makeTempDir } from "@supabase/cli-test-helpers"; +import { createHarness, makeTempDir } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { execCli, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("init", () => { - testBehaviour("creates supabase/config.toml and exits zero", async ({ run, workspace }) => { - const result = await run(["init"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); - }); + testBehaviour("creates supabase/config.toml and exits zero", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect(yield* fs.exists(path.join(workspace.path, "supabase", "config.toml"))).toBe(true); + }), + signal, + ), + ); testBehaviour( "exits non-zero if config.toml already exists without --force", - async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("force"); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + "# existing config\n", + ); + const result = yield* run(["init"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("force"); + }), + signal, + ), ); - testBehaviour("exits zero with --force when config.toml exists", async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init", "--force"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); - }); + testBehaviour("exits zero with --force when config.toml exists", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + "# existing config\n", + ); + const result = yield* run(["init", "--force"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect(yield* fs.exists(path.join(workspace.path, "supabase", "config.toml"))).toBe(true); + }), + signal, + ), + ); testBehaviour( "creates VS Code settings with --with-vscode-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-vscode-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated VS Code settings"); - expect(existsSync(join(workspace.path, ".vscode", "settings.json"))).toBe(true); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init", "--with-vscode-settings"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated VS Code settings"); + expect(yield* fs.exists(path.join(workspace.path, ".vscode", "settings.json"))).toBe( + true, + ); + }), + signal, + ), ); testBehaviour( "creates IntelliJ settings with --with-intellij-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-intellij-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated IntelliJ settings"); - expect(existsSync(join(workspace.path, ".idea", "deno.xml"))).toBe(true); - }, + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["init", "--with-intellij-settings"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated IntelliJ settings"); + expect(yield* fs.exists(path.join(workspace.path, ".idea", "deno.xml"))).toBe(true); + }), + signal, + ), ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["init", "--debug"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - }); + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["init", "--debug"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + }), + signal, + ), + ); }); describe("link", () => { @@ -65,92 +109,142 @@ describe("link", () => { // SUPABASE_PROJECT_ID, which the CLI accepts as a substitute for // --project-ref in non-TTY mode. A raw test omits it so the CLI correctly // requires the --project-ref flag. - test("exits non-zero without --project-ref in non-TTY", async () => { + test("exits non-zero without --project-ref in non-TTY", ({ signal }) => { const serverUrl = inject("replayServerUrl") as string; - const dir = makeTempDir("cli-e2e-link-no-ref-"); - using _ = dir; - const harness = createHarness({ - apiUrl: serverUrl, - accessToken: ACCESS_TOKEN, - cwd: dir.path, - }); - const result = await exec(harness, ["link"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("project-ref"); + return runE2e( + Effect.acquireUseRelease( + Effect.promise(() => makeTempDir("cli-e2e-link-no-ref-")), + (dir) => + Effect.gen(function* () { + const harness = createHarness({ + apiUrl: serverUrl, + accessToken: ACCESS_TOKEN, + cwd: dir.path, + }); + const result = yield* execCli(harness, ["link"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("project-ref"); + }), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ), + signal, + ); }); // The testBehaviour run fixture always injects SUPABASE_PROJECT_ID, which the // CLI accepts in place of --project-ref, so link proceeds to the API and succeeds. - testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", async ({ run }) => { - const result = await run(["link"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["link"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["link", "--project-ref", PROJECT_REF]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); // link's concurrent service calls (after project-status and api-keys) fail // silently, so only the first two sequential calls need fixture entries. testBehaviour.skipIf(isRecording)( "links project successfully", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - expect( - readFileSync(join(workspace.path, "supabase", ".temp", "storage-migration"), "utf8"), - ).toBe("operation-ergonomics"); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["link", "--project-ref", projectRef]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fs.exists(path.join(workspace.path, "supabase", ".temp", "project-ref")), + ).toBe(true); + expect( + new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile( + path.join(workspace.path, "supabase", ".temp", "storage-migration"), + ), + ), + ).toBe("operation-ergonomics"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "--skip-pooler uses direct connection", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef, "--skip-pooler"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - }, + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* run(["link", "--project-ref", projectRef, "--skip-pooler"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fs.exists(path.join(workspace.path, "supabase", ".temp", "project-ref")), + ).toBe(true); + }), + signal, + ), ); }); describe("unlink", () => { - testBehaviour("exits non-zero when project not linked", async ({ run }) => { - const result = await run(["unlink"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("supabase link"); - }); + testBehaviour("exits non-zero when project not linked", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["unlink"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("supabase link"); + }), + signal, + ), + ); // The success path is omitted: unlink deletes a keyring entry on success, and // Linux CI (no D-Bus session bus) makes that call exit 1. See diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index e34dc96926..858a03ace2 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -1,312 +1,418 @@ +import { Effect, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("projects", () => { describe("projects:list", () => { - testBehaviour("renders project list", async ({ run }) => { - const result = await run(["projects", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/[a-z]{20}/); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("renders project list", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/[a-z]{20}/); + expect(result.stdout).toContain("REFERENCE ID"); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["projects", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); - }); + testBehaviour("returns json output with --output json", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list", "--output", "json"]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + /* Narrows the type only: the assertion above already fails a non-array. */ + if (!Array.isArray(parsed)) return; + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); + }), + signal, + ), + ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["projects", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("includes debug output with --debug", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "list", "--debug"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["projects", "list"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:api-keys", () => { - testBehaviour("shows default and anon keys", async ({ run, projectRef }) => { - const result = await run(["projects", "api-keys", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("KEY VALUE"); - expect(result.stdout).toContain("anon"); - expect(result.stdout).toContain("default"); - }); + testBehaviour("shows default and anon keys", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "api-keys", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("KEY VALUE"); + expect(result.stdout).toContain("anon"); + expect(result.stdout).toContain("default"); + }), + signal, + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "projects", - "api-keys", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ name: "anon" })])); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "projects", + "api-keys", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "anon" })]), + ); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:create", () => { - testBehaviour("creates project with required flags", async ({ run, orgId }) => { - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - orgId, - "--db-password", - "password123", - "--region", - "us-east-1", - "--size", - "micro", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("creates project with required flags", ({ run, orgId, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + orgId, + "--db-password", + "password123", + "--region", + "us-east-1", + "--size", + "micro", + "--yes", + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REFERENCE ID"); + }), + signal, + ), + ); - testBehaviour("exits non-zero without required flags in non-TTY", async ({ run }) => { - const result = await run(["projects", "create", "--org-id", "test-org-id"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero without required flags in non-TTY", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "create", "--org-id", "test-org-id", "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Project name already in use" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 409, + body: { message: "Project name already in use" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Validation failed", - errors: [{ field: "region", message: "Invalid region" }], - }, + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { + message: "Validation failed", + errors: [{ field: "region", message: "Invalid region" }], + }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + signal, + ), + ); - testBehaviour("exits non-zero on 403 no org access", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403 no org access", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + "--yes", + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("projects:delete", () => { testBehaviour.skipIf(isRecording)( "returns 400 when project not ready for deletion", - async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Project not ready for deletion." }, + ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 400, + body: { message: "Project not ready for deletion." }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }, + signal, + ), ); testBehaviour.skipIf(isRecording)( "deletes project with --yes flag", - async ({ run, projectRef }) => { - const result = await run(["projects", "delete", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted project"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["projects", "delete", projectRef, "--yes"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted project"); + }), + signal, + ), ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["projects", "delete", PROJECT_REF, "--yes"]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/request-log.ts b/apps/cli-e2e/src/tests/request-log.ts new file mode 100644 index 0000000000..dc172c1bff --- /dev/null +++ b/apps/cli-e2e/src/tests/request-log.ts @@ -0,0 +1,15 @@ +import { Effect, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +const RequestEntry = Schema.Struct({ + method: Schema.String, + pathname: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Unknown, +}); + +/** The requests the replay server received since the last test cleared its log. */ +export const getRequestLog = Effect.fnUntraced(function* (apiUrl: string) { + const res = yield* HttpClient.get(`${apiUrl}/_ctrl/requests`); + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(RequestEntry))(res); +}); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index 9ace9e4e12..28e8b5a61d 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -1,215 +1,325 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; describe("secrets", () => { describe("secrets:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("DIGEST"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("DIGEST"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "list", + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + const parsed = yield* decodeJsonOutput(Schema.Unknown)(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["secrets", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); describe("secrets:set", () => { - testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets multiple secrets", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "set", - "FOO=bar", - "BAZ=qux", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets secrets from env file", async ({ run, workspace, projectRef }) => { - writeFileSync(join(workspace.path, ".env.local"), "FOO=bar\n"); - const result = await run([ - "secrets", - "set", - "--env-file", - ".env.local", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 422 invalid name", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, - }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero when env file not found", async ({ run }) => { - const result = await run([ - "secrets", - "set", - "--env-file", - "nonexistent.env", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("sets a single secret", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("sets multiple secrets", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "BAZ=qux", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("sets secrets from env file", ({ run, workspace, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(workspace.path, ".env.local"), "FOO=bar\n"); + const result = yield* run([ + "secrets", + "set", + "--env-file", + ".env.local", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid name", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, + }); + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero when env file not found", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "secrets", + "set", + "--env-file", + "nonexistent.env", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "secrets", + "set", + "FOO=bar", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); describe("secrets:unset", () => { - testBehaviour("removes a secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "unset", "FOO", "--project-ref", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef]); - const result = await run([ - "secrets", - "unset", - "FOO", - "BAR", - "--project-ref", - projectRef, - "--yes", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 404", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Secret not found" } }), - }); - const result = await run([ - "secrets", - "unset", - "NONEXISTENT", - "--project-ref", - PROJECT_REF, - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("removes a secret", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "unset", "FOO", "--yes", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("removes multiple secrets", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["secrets", "set", "FOO=bar", "BAR=baz", "--yes", `--remote=${projectRef}`]); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "BAR", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Secret not found" }, + }); + const result = yield* run([ + "secrets", + "unset", + "NONEXISTENT", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "secrets", + "unset", + "FOO", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/sso.e2e.test.ts b/apps/cli-e2e/src/tests/sso.e2e.test.ts index f717699f92..f117212d3b 100644 --- a/apps/cli-e2e/src/tests/sso.e2e.test.ts +++ b/apps/cli-e2e/src/tests/sso.e2e.test.ts @@ -1,8 +1,7 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect } from "vitest"; import { isRecording, PROJECT_REF, PROVIDER_ID } from "./env.ts"; -import { testBehaviour } from "./test-context.ts"; +import { decodeJsonOutput, postControl, runE2e, testBehaviour } from "./test-context.ts"; const MINIMAL_SAML_XML = ` @@ -13,94 +12,141 @@ const MINIMAL_SAML_XML = ` describe("sso", () => { describe("sso:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--output", "json", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ providers: [] }); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("SAML 2.0 support is not enabled"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", "--output", "json", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toMatchObject({ + providers: [], + }); + }), + signal, + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "list", "--debug", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "Project not found" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("SAML 2.0 support is not enabled"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["sso", "list", `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:info", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--output", "json", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), - }); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "info", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); + }), + signal, + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "info", "--output", "json", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(yield* decodeJsonOutput(Schema.Unknown)(result.stdout)).toMatchObject({ + acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), + }); + }), + signal, + ), + ); // sso info makes no API calls — no error injection tests needed }); @@ -108,533 +154,716 @@ describe("sso", () => { describe("sso:show", () => { testBehaviour.skipIf(isRecording)( "renders fixture data in output", - async ({ run, projectRef }) => { - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "show", + PROVIDER_ID, + "--output", + "json", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "shows raw SAML metadata XML with --metadata", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--metadata", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "show", + PROVIDER_ID, + "--metadata", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:add", () => { testBehaviour( "adds SAML provider via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, - ); - - testBehaviour("exits non-zero without --type", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--metadata-url", - "https://example.com/saml/metadata", - "--skip-url-validation", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"type"'); - }); + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero without --type", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "add", + "--metadata-url", + "https://example.com/saml/metadata", + "--skip-url-validation", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"type"'); + }), + signal, + ), + ); testBehaviour( "exits non-zero with both --metadata-url and --metadata-file", - async ({ run, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "https://example.com/saml/metadata", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("metadata"); - }, - ); - - testBehaviour("exits non-zero with unreachable --metadata-url", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "http://localhost:19999/saml.xml", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("HTTPS"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 422 invalid metadata", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Invalid SAML metadata" }, - }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid SAML metadata"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "https://example.com/saml/metadata", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("metadata"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with unreachable --metadata-url", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "http://localhost:19999/saml.xml", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("HTTPS"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 422 invalid metadata", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 422, + body: { message: "Invalid SAML metadata" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid SAML metadata"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:update", () => { testBehaviour.skipIf(isRecording)( "appends domain with --add-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "replaces domains with --domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "new.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("new.com"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "new.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("new.com"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "removes domain with --remove-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--remove-domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }, + ({ run, projectRef, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--remove-domains", + "example.com", + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }), + signal, + ), ); testBehaviour.skipIf(isRecording)( "updates metadata via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--metadata-file", - metadataPath, - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero with --domains and --add-domains", async ({ run }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "a.com", - "--add-domains", - "b.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("domains"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspace.path, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--metadata-file", + metadataPath, + "--yes", + `--remote=${projectRef}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero with --domains and --add-domains", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("domains"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); describe("sso:remove", () => { - testBehaviour.skipIf(isRecording)("removes a provider", async ({ run }) => { - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("removes a provider", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 401, + body: { message: "Invalid token" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 403, + body: { message: "Forbidden" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 404, + body: { message: "SSO Identity Provider not found" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 429, + body: { message: "Too Many Requests" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }), + signal, + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* postControl(`${apiUrl}/_ctrl/error-all`, { + status: 500, + body: { message: "Internal Server Error" }, + }); + const result = yield* run([ + "sso", + "remove", + PROVIDER_ID, + "--yes", + `--remote=${PROJECT_REF}`, + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index 8993de60eb..ec0b7b41a4 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -1,8 +1,8 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, inject, test } from "vitest"; -import { createHarness, exec, type CLIResult } from "@supabase/cli-test-helpers"; -import { testBehaviour } from "./test-context.ts"; +import { createHarness, type CLIResult } from "@supabase/cli-test-helpers"; +import { getRequestLog } from "./request-log.ts"; +import { type CliExecError, execCli, runE2e, testBehaviour } from "./test-context.ts"; import { ACCESS_TOKEN } from "./env.ts"; // A guaranteed-unreachable TCP address — connection is refused immediately. @@ -10,10 +10,15 @@ import { ACCESS_TOKEN } from "./env.ts"; const UNREACHABLE_DOCKER_HOST = "tcp://localhost:1"; // Minimal config.toml required by start/stop/status. -function setupStackWorkspace(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), 'project_id = "test-project"\n'); -} +const setupStackWorkspace = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + 'project_id = "test-project"\n', + ); +}); // Extends testBehaviour with a `stackRun` fixture that automatically passes // DOCKER_HOST to the CLI subprocess, pointing it at the relay server. @@ -22,11 +27,14 @@ function setupStackWorkspace(dir: string): void { // The optional `dockerHost` override lets individual tests substitute an // unreachable host to simulate Docker being unavailable. interface StackFixtures { - stackRun: (cmd: string[], opts?: { dockerHost?: string }) => Promise; + stackRun: ( + cmd: string[], + opts?: { dockerHost?: string }, + ) => Effect.Effect; } const testStack = testBehaviour.extend({ - stackRun: async ({ workspace }, use) => { + stackRun: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl") as string; const dockerHostUrl = inject("dockerHostUrl") as string; const harness = createHarness({ @@ -34,65 +42,90 @@ const testStack = testBehaviour.extend({ accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd, opts) => - exec(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + return use((cmd, opts) => + runGuard.track( + execCli(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + ), ); }, }); // `services` prints a static service matrix, so DOCKER_HOST is not needed. describe("services", () => { - testBehaviour("lists known service images", async ({ run }) => { - const result = await run(["services"]); - expect(result.exitCode).toBe(0); - // Output is a pipe-separated markdown table; verify well-known image names appear. - expect(result.stdout).toContain("postgres"); - expect(result.stdout).toContain("gotrue"); - expect(result.stdout).toContain("storage"); - }); + testBehaviour("lists known service images", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["services"]); + expect(result.exitCode).toBe(0); + // Output is a pipe-separated markdown table; verify well-known image names appear. + expect(result.stdout).toContain("postgres"); + expect(result.stdout).toContain("gotrue"); + expect(result.stdout).toContain("storage"); + }), + signal, + ), + ); }); // `status` resolves and prints the current linked project/branch on stdout // before any Docker/daemon work runs, in every output mode. describe("status", () => { - testStack("exits 1 when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["status"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/no such container/i); - }); + testStack("exits 1 when stack is not running", ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["status"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/no such container/i); + }), + signal, + ), + ); }); describe("stop", () => { - testStack("succeeds when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop"]); - expect(result.exitCode).toBe(0); - }); + testStack("succeeds when stack is not running", ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["stop"]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); // Flag validation happens before the command runs, so no Docker or API calls are made. testStack( "exits 1 with mutual-exclusion error for --project-id and --all", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop", "--project-id", "test-project", "--all"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); - }, + ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* stackRun(["stop", "--project-id", "test-project", "--all"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); + }), + signal, + ), ); }); describe("start", () => { testStack( "exits 1 with Docker error when Docker is unavailable", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - // Use an unreachable host so connection fails immediately without waiting - // for a timeout. The relay has no special handling for this case. - const result = await stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - }, + ({ workspace, stackRun, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + // Use an unreachable host so connection fails immediately without waiting + // for a timeout. The relay has no special handling for this case. + const result = yield* stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + }), + signal, + ), ); // Must run in sequence in a shared workspace so status/stop see the stack start brought up. @@ -104,29 +137,34 @@ describe("start", () => { // `seed buckets` makes storage HTTP calls (not Docker), so plain testBehaviour // with `run` is correct. describe("seed buckets", () => { - testBehaviour("creates buckets defined in config", async ({ workspace, run, apiUrl }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync( - join(workspace.path, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "", - "[api]", - // Point the local stack API at the relay server so bucket creation - // calls are captured. - `port = ${new URL(apiUrl).port}`, - "", - "[storage.buckets.my-bucket]", - "public = false", - ].join("\n"), - ); - const result = await run(["seed", "buckets"]); - expect(result.exitCode).toBe(0); - const requests = await fetch(`${apiUrl}/_ctrl/requests`).then( - (r) => r.json() as Promise>, - ); - expect(requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket")).toBe( - true, - ); - }); + testBehaviour("creates buckets defined in config", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + // Point the local stack API at the relay server so bucket creation + // calls are captured. + `port = ${new URL(apiUrl).port}`, + "", + "[storage.buckets.my-bucket]", + "public = false", + ].join("\n"), + ); + const result = yield* run(["seed", "buckets"]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket"), + ).toBe(true); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index b0975ba22d..e0b9173a1a 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -1,437 +1,583 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; +import { HttpClient } from "effect/http"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { getRequestLog } from "./request-log.ts"; +import { postControl, runE2e, testBehaviour } from "./test-context.ts"; const BUCKET = "cli-e2e-bucket"; -const LOCAL_FLAGS = ["--experimental", "--local"]; - -function setupStorageWorkspace(dir: string, relayUrl: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), +const EXPERIMENTAL_FLAGS = ["--experimental"]; + +const setupStorageWorkspace = Effect.fnUntraced(function* (dir: string, relayUrl: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), ['project_id = "test-project"', "", "[api]", `external_url = "${relayUrl}"`].join("\n"), ); - writeFileSync(join(dir, "upload.txt"), "test upload content"); -} - -interface RequestEntry { - method: string; - pathname: string; - headers: Record; - body: unknown; -} - -async function getRequestLog(apiUrl: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/requests`); - return res.json() as Promise; -} + yield* fs.writeFileString(path.join(dir, "upload.txt"), "test upload content"); +}); -async function injectGlobalError(apiUrl: string, status: number, message: string): Promise { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status, body: { message } }), - }); -} +const injectGlobalError = (apiUrl: string, status: number, message: string) => + postControl(`${apiUrl}/_ctrl/error-all`, { status, body: { message } }); -async function clearOverrides(apiUrl: string): Promise { - await fetch(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" }); -} +const clearOverrides = (apiUrl: string) => HttpClient.del(`${apiUrl}/_ctrl/overrides`); describe("storage ls", () => { - testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("hello.txt"); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects in bucket", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello.txt"); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("lists objects recursively", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, "-r", `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects recursively", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "ls", + ...EXPERIMENTAL_FLAGS, + "-r", + `ss:///${BUCKET}/`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); describe("storage cp", () => { - testBehaviour("uploads local file to storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, - ), - ).toBe(true); - }); + testBehaviour("uploads local file to storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, + ), + ).toBe(true); + }), + signal, + ), + ); - testBehaviour("passes --cache-control header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "--cache-control", - "no-cache", - "upload.txt", - `ss:///${BUCKET}/cached.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); - }); + testBehaviour("passes --cache-control header on upload", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "--cache-control", + "no-cache", + "upload.txt", + `ss:///${BUCKET}/cached.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); + }), + signal, + ), + ); - testBehaviour("passes --content-type header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "--content-type", - "application/json", - "upload.txt", - `ss:///${BUCKET}/typed.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["content-type"]).toContain("application/json"); - }); + testBehaviour("passes --content-type header on upload", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "--content-type", + "application/json", + "upload.txt", + `ss:///${BUCKET}/typed.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["content-type"]).toContain("application/json"); + }), + signal, + ), + ); - testBehaviour("exits 1 when source file not found", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "nonexistent.txt", - `ss:///${BUCKET}/x.txt`, - ]); - expect(result.exitCode).toBe(1); - }); + testBehaviour("exits 1 when source file not found", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "nonexistent.txt", + `ss:///${BUCKET}/x.txt`, + ]); + expect(result.exitCode).toBe(1); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/upload.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("downloads file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/hello.txt`, - "hello-download.txt", - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("downloads file from storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/hello.txt`, + "hello-download.txt", + ]); + expect(result.exitCode).toBe(0); + }), + signal, + ), + ); }); describe("storage mv", () => { - testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload source file so the move has something to move in staging. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/mv-source.txt`]); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/mv-source.txt`, - `ss:///${BUCKET}/mv-dest.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const moveReq = requests.find( - (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", - ); - expect(moveReq).toBeDefined(); - expect(moveReq?.body).toMatchObject({ - bucketId: BUCKET, - sourceKey: "mv-source.txt", - destinationKey: "mv-dest.txt", - }); - }); + testBehaviour("moves file within bucket", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload source file so the move has something to move in staging. + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/mv-source.txt`, + ]); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/mv-source.txt`, + `ss:///${BUCKET}/mv-dest.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const moveReq = requests.find( + (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", + ); + expect(moveReq).toBeDefined(); + expect(moveReq?.body).toMatchObject({ + bucketId: BUCKET, + sourceKey: "mv-source.txt", + destinationKey: "mv-dest.txt", + }); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "mv", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/a.txt`, + `ss:///${BUCKET}/b.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); describe("storage rm", () => { - testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload the file to remove. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-target.txt`]); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/rm-target.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); - }); + testBehaviour("removes a file from storage", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload the file to remove. + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/rm-target.txt`, + ]); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/rm-target.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); + }), + signal, + ), + ); - testBehaviour("removes multiple files", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - writeFileSync(join(workspace.path, "file2.txt"), "second file"); - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]); - await run(["storage", "cp", ...LOCAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/rm-a.txt`, - `ss:///${BUCKET}/rm-b.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); - }); + testBehaviour("removes multiple files", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* fs.writeFileString(path.join(workspace.path, "file2.txt"), "second file"); + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/rm-a.txt`, + ]); + yield* run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "file2.txt", + `ss:///${BUCKET}/rm-b.txt`, + ]); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/rm-a.txt`, + `ss:///${BUCKET}/rm-b.txt`, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); + }), + signal, + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* run([ + "storage", + "rm", + "--yes", + ...EXPERIMENTAL_FLAGS, + `ss:///${BUCKET}/file.txt`, + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }), + signal, + ), + ); }); diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts new file mode 100644 index 0000000000..c43305a73b --- /dev/null +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -0,0 +1,46 @@ +import { Effect } from "effect"; +import { describe, expect } from "vitest"; +import { linkProject } from "./link-project.ts"; +import { getRequestLog } from "./request-log.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; + +const DEAD_DB_URL = "postgresql://postgres:postgres@localhost:1/postgres"; + +describe("remote target selection", () => { + testBehaviour( + "reads a space-separated --remote as --remote=", + ({ run, projectRef, workspace, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* run([ + "db", + "advisors", + "--type", + "security", + "--remote", + projectRef, + ]); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( + `GET /v1/projects/${projectRef}/advisors/security`, + ); + }), + signal, + ), + ); + + testBehaviour("rejects --remote= on a hosted command", ({ run, apiUrl, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--remote takes a project ref or a branch name."); + const requests = yield* getRequestLog(apiUrl); + expect(requests.filter((r) => r.pathname.endsWith("/secrets"))).toEqual([]); + }), + signal, + ), + ); +}); diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 9d6bc4e942..6470256ac2 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -1,63 +1,105 @@ -import { chmodSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context.ts"; +import { runE2e, testBehaviour } from "./test-context.ts"; describe("telemetry", () => { describe("telemetry:enable", () => { - testBehaviour("enables telemetry", async ({ run }) => { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); + testBehaviour("enables telemetry", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "enable"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + signal, + ), + ); - testBehaviour("exits non-zero on unwritable config dir", async ({ run, workspace }) => { - chmodSync(workspace.path, 0o555); - try { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).not.toBe(0); - } finally { - chmodSync(workspace.path, 0o755); - } - }); + testBehaviour("exits non-zero on unwritable config dir", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.acquireUseRelease( + fs.chmod(workspace.path, 0o555), + () => + Effect.gen(function* () { + const result = yield* run(["telemetry", "enable"]); + expect(result.exitCode).not.toBe(0); + }), + () => fs.chmod(workspace.path, 0o755), + ); + }), + signal, + ), + ); }); describe("telemetry:disable", () => { - testBehaviour("disables telemetry", async ({ run }) => { - const result = await run(["telemetry", "disable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); + testBehaviour("disables telemetry", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "disable"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + signal, + ), + ); }); describe("telemetry:status", () => { - testBehaviour("shows current telemetry state", async ({ run }) => { - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); - }); + testBehaviour("shows current telemetry state", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + }), + signal, + ), + ); - testBehaviour("round-trip: enable then status shows enabled", async ({ run }) => { - await run(["telemetry", "enable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); + testBehaviour("round-trip: enable then status shows enabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["telemetry", "enable"]); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + signal, + ), + ); - testBehaviour("round-trip: disable then status shows disabled", async ({ run }) => { - await run(["telemetry", "disable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); + testBehaviour("round-trip: disable then status shows disabled", ({ run, signal }) => + runE2e( + Effect.gen(function* () { + yield* run(["telemetry", "disable"]); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + signal, + ), + ); - testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => { - const telemetryPath = join(workspace.path, "telemetry.json"); - writeFileSync(telemetryPath, "{{not valid json}}"); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); - }); + testBehaviour("handles corrupted config gracefully", ({ run, workspace, signal }) => + runE2e( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const telemetryPath = path.join(workspace.path, "telemetry.json"); + yield* fs.writeFileString(telemetryPath, "{{not valid json}}"); + const result = yield* run(["telemetry", "status"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + const telemetry = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(telemetryPath), + ); + expect(() => JSON.parse(telemetry)).not.toThrow(); + }), + signal, + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/test-context.ts b/apps/cli-e2e/src/tests/test-context.ts index 4abaa73b84..92e22b1d64 100644 --- a/apps/cli-e2e/src/tests/test-context.ts +++ b/apps/cli-e2e/src/tests/test-context.ts @@ -1,8 +1,12 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Data, Effect, Formatter, Layer, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { inject, test } from "vitest"; import { createHarness, exec, makeTempDir, + type CLIHarness, type CLIResult, type TempDir, } from "@supabase/cli-test-helpers"; @@ -22,12 +26,79 @@ function scenarioSlug(task: { name: string; suite?: { name: string } | null }): type ExecOptions = NonNullable[2]>; +/** The CLI subprocess could not be spawned or awaited. */ +export class CliExecError extends Data.TaggedError("CliExecError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The replay server refused to load the test's scenario. */ +class ScenarioLoadError extends Data.Error<{ readonly message: string }> {} + +/** CLI stdout did not decode as the expected JSON. */ +class JsonOutputError extends Data.Error<{ readonly message: string }> {} + +/** A test body passed without executing its CLI runner. */ +class RunNotExecutedError extends Data.Error<{ readonly message: string }> {} + +/** Runs the compiled CLI against a harness target. */ +export const execCli = (harness: CLIHarness, cmd: string[], execOpts?: ExecOptions) => + Effect.tryPromise({ + try: () => exec(harness, cmd, execOpts), + catch: (cause) => + new CliExecError({ message: cause instanceof Error ? cause.message : String(cause), cause }), + }); + +const sendControl = (url: string, body: unknown) => + HttpClientRequest.post(url).pipe( + HttpClientRequest.bodyJson(body), + Effect.flatMap(HttpClient.execute), + ); + +/** POSTs a JSON body to a replay server control endpoint; a non-2xx answer fails. */ +export const postControl = (url: string, body: unknown) => + sendControl(url, body).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + +const httpLayer = Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), +); + +const e2eLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer, httpLayer); + +/** Runs a test body at the Vitest edge with the file system, path, and HTTP services. */ +export const runE2e = ( + body: Effect.Effect>, + signal?: AbortSignal, +): Promise => Effect.runPromise(body.pipe(Effect.provide(e2eLayer)), { signal }); + +/** Decodes CLI stdout as JSON; a failure keeps the schema message plus a bounded stdout excerpt. */ +export const decodeJsonOutput = + (schema: S) => + (stdout: string) => + Schema.decodeEffect(Schema.fromJsonString(schema))(stdout).pipe( + Effect.mapError( + (error) => + new JsonOutputError({ + message: `${error.message}\n stdout: ${Formatter.format(stdout.slice(0, 200))}`, + }), + ), + ); + +const ScenarioFailure = Schema.Struct({ message: Schema.String }); + +/** Marks a CLI run as executed for the current test. */ +interface RunGuard { + readonly track: (effect: Effect.Effect) => Effect.Effect; +} + interface BehaviourFixtures { + runGuard: RunGuard; projectRef: string; orgId: string; workspace: TempDir; - run: (cmd: string[], execOpts?: ExecOptions) => Promise; - runNoProjectId: (cmd: string[]) => Promise; + run: (cmd: string[], execOpts?: ExecOptions) => Effect.Effect; + runNoProjectId: (cmd: string[]) => Effect.Effect; apiUrl: string; storageBucket: string; pgMockPort: number; @@ -47,42 +118,63 @@ interface BehaviourFixtures { * server knows which ordered interaction sequence to serve. Auto-clears the * request log, error overrides, and active scenario after every test. */ export const testBehaviour = test.extend({ - projectRef: async ({ task: _task }, use) => { - await use(inject("projectRef") as string); + /* Fails a test that passed without executing a CLI run, e.g. because its body returned the + Effect without `runE2e`. Checked once the test finished, after every fixture cleaned up. */ + runGuard: ({ task, onTestFinished }, use) => { + let executed = false; + onTestFinished(() => { + if (!executed && task.result?.state === "pass") { + return Effect.runPromise( + new RunNotExecutedError({ + message: `"${task.name}" passed without executing its run Effect; return runE2e(Effect.gen(...), signal) from the test body`, + }), + ); + } + }); + return use({ + track: (effect) => + Effect.suspend(() => { + executed = true; + return effect; + }), + }); }, - orgId: async ({ task: _task }, use) => { - await use(inject("orgId") as string); - }, + projectRef: ({ task: _task }, use) => use(inject("projectRef")), + + orgId: ({ task: _task }, use) => use(inject("orgId")), - workspace: async ({ task }, use) => { + workspace: ({ task }, use) => { const serverUrl = inject("replayServerUrl"); const slug = scenarioSlug(task); - // Truncate to 40 chars to keep temp dir names manageable. - const dir = makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`); + return Effect.runPromise( + Effect.gen(function* () { + // Truncate to 40 chars to keep temp dir names manageable. + const dir = yield* Effect.promise(() => makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`)); - const res = await fetch(`${serverUrl}/_ctrl/scenario`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: slug }), - }); - if (!res.ok) { - dir[Symbol.dispose](); - const { message } = (await res.json()) as { message: string }; - throw new Error(message); - } - - await use(dir); - - dir[Symbol.dispose](); - await Promise.all([ - fetch(`${serverUrl}/_ctrl/requests`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/overrides`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/scenario`, { method: "DELETE" }), - ]); + const res = yield* sendControl(`${serverUrl}/_ctrl/scenario`, { name: slug }); + if (res.status < 200 || res.status >= 300) { + yield* Effect.promise(() => dir[Symbol.asyncDispose]()); + const { message } = yield* HttpClientResponse.schemaBodyJson(ScenarioFailure)(res); + return yield* new ScenarioLoadError({ message }); + } + + yield* Effect.promise(() => use(dir)); + + yield* Effect.promise(() => dir[Symbol.asyncDispose]()); + yield* Effect.all( + [ + HttpClient.del(`${serverUrl}/_ctrl/requests`), + HttpClient.del(`${serverUrl}/_ctrl/overrides`), + HttpClient.del(`${serverUrl}/_ctrl/scenario`), + ], + { concurrency: "unbounded", discard: true }, + ); + }).pipe(Effect.provide(httpLayer)), + ); }, - run: async ({ workspace }, use) => { + run: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness({ apiUrl: serverUrl, @@ -90,28 +182,22 @@ export const testBehaviour = test.extend({ cwd: workspace.path, projectId: inject("projectRef") as string, }); - await use((cmd, execOpts) => exec(harness, cmd, execOpts)); + return use((cmd, execOpts) => runGuard.track(execCli(harness, cmd, execOpts))); }, - runNoProjectId: async ({ workspace }, use) => { + runNoProjectId: ({ runGuard, workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness({ apiUrl: serverUrl, accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd) => exec(harness, cmd)); + return use((cmd) => runGuard.track(execCli(harness, cmd))); }, - storageBucket: async ({ task: _task }, use) => { - await use(inject("storageBucket") as string); - }, + storageBucket: ({ task: _task }, use) => use(inject("storageBucket")), - apiUrl: async ({ task: _task }, use) => { - await use(inject("replayServerUrl")); - }, + apiUrl: ({ task: _task }, use) => use(inject("replayServerUrl")), - pgMockPort: async ({ task: _task }, use) => { - await use(inject("pgMockPort") as number); - }, + pgMockPort: ({ task: _task }, use) => use(inject("pgMockPort")), }); diff --git a/apps/cli-e2e/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index ae9ea9a70f..df8a49c454 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -1,34 +1,73 @@ +import { + Config, + ConfigProvider, + Data, + Effect, + Exit, + Layer, + Option, + Predicate, + Schedule, + Schema, + Scope, +} from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/http"; import type { ProvidedContext } from "vitest"; import { startPgMock } from "../src/server/pg-mock.ts"; import { startReplayServer } from "../src/server/replay-server.ts"; -import { ACCESS_TOKEN, isRecording, ORG_ID, PROJECT_REF } from "../src/tests/env.ts"; +import { ACCESS_TOKEN, isRecording, ORG_ID, PROJECT_REF, STAGING_URL } from "../src/tests/env.ts"; import { cleanupProjectsByName, createTestProject, deleteTestProject, generateDbPassword, resolveOrgId, + StagingProjectError, waitForProjectReady, } from "./staging-project.ts"; import "./provided-context.ts"; // centralized `inject()` key augmentation const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; -function resolveDockerSocket(): string { - const dockerHost = process.env["DOCKER_HOST"]; +const resolveDockerSocket = Effect.gen(function* () { + const dockerHost = Option.getOrUndefined(yield* Config.option(Config.String("DOCKER_HOST"))); if (dockerHost?.startsWith("unix://")) return dockerHost.slice("unix://".length); return "/var/run/docker.sock"; -} +}); + +/** The api-keys endpoint answered with a non-2xx status. */ +class ApiKeysNotReady extends Data.TaggedError("ApiKeysNotReady")<{ + readonly response: HttpClientResponse.HttpClientResponse; +}> {} + +const ApiKeys = Schema.Array( + Schema.Struct({ name: Schema.String, api_key: Schema.optionalKey(Schema.NullOr(Schema.String)) }), +); -export async function setup({ - provide, -}: { - provide: (key: K, value: ProvidedContext[K]) => void; -}) { +const setupLayer = Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ConfigProvider.layer(Effect.sync(() => ConfigProvider.fromEnv({ preserveEmptyStrings: true }))), +); + +type Provide = (key: K, value: ProvidedContext[K]) => void; + +const globalSetup = Effect.fn("E2eSetup.globalSetup")(function* (provide: Provide) { const pgMock = startPgMock(); provide("pgMockPort", pgMock.port); - const server = await startReplayServer({ fixturesDir: FIXTURES_DIR, pgMock }); + const server = yield* startReplayServer({ + fixturesDir: FIXTURES_DIR, + pgMock, + record: isRecording, + stagingUrl: STAGING_URL, + }); provide("replayServerUrl", server.url); // Docker host URL: relay server in TCP form so DOCKER_HOST env can point at it. @@ -37,94 +76,112 @@ export async function setup({ const dockerHostUrl = server.url.replace(/^http:\/\//, "tcp://"); provide("dockerHostUrl", dockerHostUrl); + /* The replay server stops after this, when its own finalizers run. */ + const stopPgMock = Effect.sync(() => pgMock.stop()); + if (!isRecording) { // Replay mode — no real API calls; PROJECT_REF and ORG_ID default to the ref // recorded fixtures use. provide("projectRef", PROJECT_REF); provide("orgId", ORG_ID); provide("storageBucket", "cli-e2e-bucket"); - return async () => { - pgMock.stop(); - await server.stop(); - }; + yield* Effect.addFinalizer(() => stopPgMock); + return; } // Record mode — wire up Docker proxy so Docker SDK calls (via DOCKER_HOST) are // intercepted by the relay server and forwarded to the real Docker socket. - server.setDockerProxyUrl(resolveDockerSocket()); + server.setDockerProxyUrl(yield* resolveDockerSocket); // Record mode — resolve org, then wipe any projects left over from previous // failed recording runs before creating a fresh dedicated test project. - const orgId = await resolveOrgId(server.url); + const orgId = yield* resolveOrgId(server.url); // Delete any orphaned projects whose names would conflict with what the tests // are about to create. Runs before any scenario is loaded so these API calls // go straight to staging and are not captured in any scenario fixture. - await cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]); + yield* cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]); // Create a fresh project for this recording run. Its ref is used by branches, // functions, secrets, and api-keys tests. - const projectRef = await createTestProject( + const projectRef = yield* createTestProject( server.url, orgId, "cli-e2e-test", - generateDbPassword(), + yield* generateDbPassword, ); provide("projectRef", projectRef); provide("orgId", orgId); // Wire storage proxy so /storage/v1/ calls from --local mode reach staging. - const stagingApiUrl = process.env["SUPABASE_STAGING_URL"]!; + const stagingApiUrl = STAGING_URL!; // Wait for the project to be fully initialised before fetching api-keys — the // api-keys endpoint is unavailable while the project is in COMING_SOON/BUILDING state. - await waitForProjectReady(stagingApiUrl, projectRef); + yield* waitForProjectReady(stagingApiUrl, projectRef); // Retry api-keys fetch: even after ACTIVE_HEALTHY, the endpoint may briefly return 4xx. - let serviceRoleKey = ""; - for (let attempt = 1; attempt <= 12; attempt++) { - const keysRes = await fetch(`${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, { + const fetchServiceRoleKey = Effect.gen(function* () { + const keysRes = yield* HttpClient.get(`${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, }); - if (keysRes.ok) { - const keys = (await keysRes.json()) as Array<{ name: string; api_key: string }>; - serviceRoleKey = keys.find((k) => k.name === "service_role")?.api_key ?? ""; - break; + if (keysRes.status < 200 || keysRes.status >= 300) { + return yield* new ApiKeysNotReady({ response: keysRes }); } - if (attempt === 12) { - throw new Error(`Failed to fetch api-keys after 12 attempts: ${await keysRes.text()}`); - } - await new Promise((r) => setTimeout(r, 10_000)); - } + const keys = yield* HttpClientResponse.schemaBodyJson(ApiKeys)(keysRes); + return keys.find((k) => k.name === "service_role")?.api_key ?? ""; + }); + const serviceRoleKey = yield* fetchServiceRoleKey.pipe( + Effect.retry({ + schedule: Schedule.spaced("10 seconds"), + times: 11, + while: (error) => Predicate.isTagged(error, "ApiKeysNotReady"), + }), + Effect.catchTag("ApiKeysNotReady", ({ response }) => + Effect.gen(function* () { + return yield* new StagingProjectError({ + message: `Failed to fetch api-keys after 12 attempts: ${yield* response.text}`, + }); + }), + ), + ); const storageBaseUrl = `https://${projectRef}.supabase.red`; server.setStorageProxyUrl(storageBaseUrl); server.setStorageProxyAuth(serviceRoleKey); // Create test bucket and seed a file — direct calls to staging, not via relay. - await fetch(`${storageBaseUrl}/storage/v1/bucket`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: "cli-e2e-bucket", name: "cli-e2e-bucket", public: false }), - }); - await fetch(`${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "text/plain", - }, - body: "hello world", + yield* HttpClientRequest.post(`${storageBaseUrl}/storage/v1/bucket`, { + headers: { Authorization: `Bearer ${serviceRoleKey}` }, + }).pipe( + HttpClientRequest.bodyJson({ id: "cli-e2e-bucket", name: "cli-e2e-bucket", public: false }), + Effect.flatMap(HttpClient.execute), + ); + yield* HttpClient.post(`${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, { + headers: { Authorization: `Bearer ${serviceRoleKey}` }, + body: HttpBody.text("hello world", "text/plain"), }); provide("storageBucket", "cli-e2e-bucket"); - return async () => { - // The projects:delete test is self-contained (it creates and deletes its own - // "to-delete" project). The projects:create test creates "my-project" but - // does not delete it, so we clean it up here. - await cleanupProjectsByName(server.url, ["my-project"]); - await deleteTestProject(server.url, projectRef); - pgMock.stop(); - await server.stop(); - }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + // The projects:delete test is self-contained (it creates and deletes its own + // "to-delete" project). The projects:create test creates "my-project" but + // does not delete it, so we clean it up here. + yield* cleanupProjectsByName(server.url, ["my-project"]); + yield* deleteTestProject(server.url, projectRef); + yield* stopPgMock; + }).pipe(Effect.orDie), + ); +}); + +export function setup({ provide }: { provide: Provide }) { + return Effect.runPromise( + Effect.gen(function* () { + const scope = yield* Scope.make(); + yield* globalSetup(provide).pipe(Scope.provide(scope)); + return scope; + }).pipe( + Effect.map((scope) => () => Effect.runPromise(Scope.close(scope, Exit.void))), + Effect.provide(setupLayer), + ), + ); } diff --git a/apps/cli-e2e/tests/staging-project.ts b/apps/cli-e2e/tests/staging-project.ts index 57ae218d49..1f7c3151dc 100644 --- a/apps/cli-e2e/tests/staging-project.ts +++ b/apps/cli-e2e/tests/staging-project.ts @@ -1,4 +1,17 @@ import { randomBytes } from "node:crypto"; +import { + Clock, + Config, + Console, + Data, + Effect, + Formatter, + Option, + Predicate, + Schedule, + Schema, +} from "effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { createHarness, exec } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, REGION } from "../src/tests/env.ts"; @@ -10,31 +23,84 @@ function harness(apiUrl: string) { return createHarness({ apiUrl, accessToken: ACCESS_TOKEN }); } +/** A record-setup step against the staging project failed. */ +export class StagingProjectError extends Data.Error<{ readonly message: string }> {} + +/** The CLI subprocess for a record-setup step could not be spawned or awaited. */ +class StagingCliError extends Data.TaggedError("StagingCliError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const runCli = (apiUrl: string, args: string[]) => + Effect.tryPromise({ + try: () => exec(harness(apiUrl), args), + catch: (cause) => + new StagingCliError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + const PROJECT_REF_RE = /^[a-z]{20}$/; const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); +const ProjectStatus = Schema.Struct({ status: Schema.optionalKey(Schema.String) }); +const Orgs = Schema.fromJsonString(Schema.Array(Schema.Struct({ id: Schema.String }))); +const CreatedProject = Schema.fromJsonString( + Schema.Struct({ id: Schema.optionalKey(Schema.String), ref: Schema.optionalKey(Schema.String) }), +); +const ListedProjects = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + id: Schema.String, + ref: Schema.optionalKey(Schema.String), + name: Schema.String, + }), + ), +); + +/* Schema's JSON errors drop the parser detail, so the failure keeps a bounded stdout excerpt. */ +const decodeStdout = (schema: Schema.Codec, stdout: string) => + Schema.decodeEffect(schema)(stdout).pipe( + Effect.mapError( + (error) => + new StagingProjectError({ + message: `${error.message}\n stdout: ${Formatter.format(stdout.slice(0, 200))}`, + }), + ), + ); /** A DB password for a throwaway recording project. Randomised per call * (overridable via CLI_E2E_DB_PASSWORD) so no static credential is committed — * the project is deleted on teardown anyway. */ -export function generateDbPassword(): string { - return process.env["CLI_E2E_DB_PASSWORD"] ?? `cli-e2e-${randomBytes(12).toString("hex")}`; -} +export const generateDbPassword = Effect.gen(function* () { + const override = Option.getOrUndefined( + yield* Config.option(Config.String("CLI_E2E_DB_PASSWORD")), + ); + return override ?? `cli-e2e-${randomBytes(12).toString("hex")}`; +}); -export async function resolveOrgId(apiUrl: string): Promise { - const result = await exec(harness(apiUrl), ["orgs", "list", "--output", "json"]); - if (result.exitCode !== 0) throw new Error(`orgs list failed: ${result.stderr}`); - const first = (JSON.parse(result.stdout) as Array<{ id: string }>)[0]?.id; - if (!first) throw new Error("No orgs found — cannot create test project"); +export const resolveOrgId = Effect.fn("StagingProject.resolveOrgId")(function* (apiUrl: string) { + const result = yield* runCli(apiUrl, ["orgs", "list", "--output", "json"]); + if (result.exitCode !== 0) { + return yield* new StagingProjectError({ message: `orgs list failed: ${result.stderr}` }); + } + const first = (yield* decodeStdout(Orgs, result.stdout))[0]?.id; + if (!first) { + return yield* new StagingProjectError({ + message: "No orgs found — cannot create test project", + }); + } return first; -} +}); -export async function createTestProject( +export const createTestProject = Effect.fn("StagingProject.createTestProject")(function* ( apiUrl: string, orgId: string, name: string, password: string, -): Promise { - const result = await exec(harness(apiUrl), [ +) { + const result = yield* runCli(apiUrl, [ "projects", "create", name, @@ -47,74 +113,96 @@ export async function createTestProject( "--output", "json", ]); - if (result.exitCode !== 0) throw new Error(`projects create failed: ${result.stderr}`); - const project = JSON.parse(result.stdout) as { id?: string; ref?: string }; + if (result.exitCode !== 0) { + return yield* new StagingProjectError({ message: `projects create failed: ${result.stderr}` }); + } + const project = yield* decodeStdout(CreatedProject, result.stdout); const ref = project.ref ?? project.id; if (!ref || !PROJECT_REF_RE.test(ref)) { - throw new Error(`Unexpected project ref from create: ${result.stdout}`); + return yield* new StagingProjectError({ + message: `Unexpected project ref from create: ${result.stdout}`, + }); } return ref; -} +}); // `throwOnError` surfaces deletion failures when a caller needs to fail loudly; // record setup keeps the lenient default. -export async function deleteTestProject( +export const deleteTestProject = Effect.fn("StagingProject.deleteTestProject")(function* ( apiUrl: string, projectRef: string, opts: { throwOnError?: boolean } = {}, -): Promise { - try { - const result = await exec(harness(apiUrl), ["projects", "delete", projectRef, "--yes"]); +) { + yield* Effect.gen(function* () { + const result = yield* runCli(apiUrl, ["projects", "delete", projectRef, "--yes"]); if (result.exitCode !== 0) { - throw new Error(`projects delete exited ${result.exitCode}: ${result.stderr}`); + return yield* new StagingProjectError({ + message: `projects delete exited ${result.exitCode}: ${result.stderr}`, + }); } - } catch (err) { - if (opts.throwOnError) throw err; - console.error(`Warning: failed to delete test project ${projectRef}:`, err); - } -} + }).pipe( + Effect.catch((err) => + opts.throwOnError + ? Effect.fail(err) + : Console.error( + `Warning: failed to delete test project ${projectRef}:`, + /* A Data.Error logs as a bare { message } object; log it as an Error. */ + Predicate.isTagged(err, "StagingCliError") ? err.cause : new Error(err.message), + ), + ), + ); +}); -export async function cleanupProjectsByName(apiUrl: string, names: string[]): Promise { - const listResult = await exec(harness(apiUrl), ["projects", "list", "--output", "json"]); +export const cleanupProjectsByName = Effect.fn("StagingProject.cleanupProjectsByName")(function* ( + apiUrl: string, + names: string[], +) { + const listResult = yield* runCli(apiUrl, ["projects", "list", "--output", "json"]); if (listResult.exitCode !== 0) return; - const projects = JSON.parse(listResult.stdout) as Array<{ - id: string; - ref?: string; - name: string; - }>; + const projects = yield* decodeStdout(ListedProjects, listResult.stdout); for (const project of projects.filter((p) => names.includes(p.name))) { const ref = project.ref ?? project.id; if (ref && PROJECT_REF_RE.test(ref)) { - await exec(harness(apiUrl), ["projects", "delete", ref, "--yes"]); + yield* runCli(apiUrl, ["projects", "delete", ref, "--yes"]); } } -} +}); /** Poll the Management API until the recording project is ACTIVE_HEALTHY. */ -export async function waitForProjectReady( +export const waitForProjectReady = Effect.fn("StagingProject.waitForProjectReady")(function* ( apiBaseUrl: string, projectRef: string, timeoutMs = 300_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const project = (await res.json()) as { status?: string }; - if (project.status === "ACTIVE_HEALTHY") return; - if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { - throw new Error( - `Project ${projectRef} entered terminal status ${project.status} during provisioning`, - ); - } - } else { - await res.body?.cancel(); +) { + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const deadline = (yield* Clock.currentTimeMillis) + timeoutMs; + const poll = Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= deadline) { + return yield* new StagingProjectError({ + message: `Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`, + }); } - await new Promise((r) => setTimeout(r, 5_000)); - } - throw new Error(`Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`); -} + return yield* Effect.scoped( + Effect.gen(function* () { + const res = yield* client.get(`${apiBaseUrl}/v1/projects/${projectRef}`, { + headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, + }); + /* A failed poll's body is cancelled when this scope closes. */ + if (res.status < 200 || res.status >= 300) return false; + const project = yield* HttpClientResponse.schemaBodyJson(ProjectStatus)(res); + if (project.status === "ACTIVE_HEALTHY") return true; + if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { + return yield* new StagingProjectError({ + message: `Project ${projectRef} entered terminal status ${project.status} during provisioning`, + }); + } + return false; + }), + ); + }); + yield* poll.pipe( + Effect.repeat({ schedule: Schedule.spaced("5 seconds"), until: (ready) => ready }), + ); +}); diff --git a/apps/cli-e2e/vitest.config.ts b/apps/cli-e2e/vitest.config.ts index bb87f884aa..7a876e3529 100644 --- a/apps/cli-e2e/vitest.config.ts +++ b/apps/cli-e2e/vitest.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ hookTimeout: 30_000, sequence: { sequencer: class extends BaseSequencer { - override async sort(files: TestSpecification[]) { - return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)); + override sort(files: TestSpecification[]) { + return Promise.resolve([...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId))); } }, }, diff --git a/apps/cli-go/.gitignore b/apps/cli-go/.gitignore deleted file mode 100644 index 32520228b6..0000000000 --- a/apps/cli-go/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# General -.DS_Store -.env - -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib -/cli -/main -/supabase-go - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# IDE -/.vscode -/.idea - -# NPM -node_modules -package-lock.json - -# Initialized by cli for local testing -/supabase diff --git a/apps/cli-go/.golangci.yml b/apps/cli-go/.golangci.yml deleted file mode 100644 index 711143ee31..0000000000 --- a/apps/cli-go/.golangci.yml +++ /dev/null @@ -1,49 +0,0 @@ -version: "2" -linters: - enable: - - dogsled - - dupl - - gosec - - misspell - - nakedret - - staticcheck - - unconvert - - unparam - - whitespace - settings: - dupl: - threshold: 250 - exclusions: - generated: lax - rules: - - text: "ST1003:" - linters: - - staticcheck - - path: _test\.go - linters: - - gosec - text: G101 - - path: internal/utils/docker\.go - linters: - - gosec - text: G117 - - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ -formatters: - enable: - - gofmt - - goimports - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md deleted file mode 100644 index c53f8d6c91..0000000000 --- a/apps/cli-go/CONTRIBUTING.md +++ /dev/null @@ -1,50 +0,0 @@ -# Welcome to Supabase CLI contributing guide - -## Release process - -We release to stable channel every two weeks via `main` branch. - -We release to beta channel on merge to `develop` branch. - -Hotfixes are released manually. Follow these steps: - -1. Create a new branch named `N.N.x` from latest stable version. For eg. - 1. If stable is on `v1.2.3` and beta is on `v1.3.6`, create `1.2.x` branch. - 2. If stable is on `v1.3.1` and beta is on `v1.3.6`, create `1.3.x` branch (or simply release all patch versions). -2. Cherry-pick your hotfix on top of `N.N.x` branch. -3. Run the [Release (Beta)](https://github.com/supabase/cli/actions/workflows/release-beta.yml) workflow targetting `N.N.x` branch. -4. Verify your hotfix locally with `npx supabase@N.N.x help` -5. Edit [GitHub releases](https://github.com/supabase/cli/releases) to set your hotfix pre-release as latest stable. - -After promoting the next beta version to stable, previous `N.N.x` branches may be deleted. - -To revert a stable release, set a previous release to latest. This will update brew and scoop to an old version. There's no need to revert npm as it supports version pinning. - -## Unit testing - -All new code should aim to improve [test coverage](https://coveralls.io/github/supabase/cli). - -We use mock objects for unit testing code that interacts with external systems, such as - -- local filesystem (via [afero](https://github.com/spf13/afero)) -- Postgres database (via [pgmock](https://github.com/jackc/pgmock)) -- Supabase API (via [gock](https://github.com/h2non/gock)) - -Wrappers and test helper methods can be found under [internal/testing](internal/testing). - -Integration tests are created under [test](test). To run all tests: - -```bash -go test ./... -race -v -count=1 -failfast -``` - -## API client - -The Supabase API client is generated from OpenAPI spec. See [our guide](api/README.md) for updating the client and types. - -## Testing local pg-delta builds - -The Go binary no longer runs pg-delta. The TypeScript CLI bundles -`@supabase/pg-delta` in-process. To test a local pg-delta build, update the -`@supabase/pg-delta` dependency pin in `apps/cli/package.json` / -`pnpm-workspace.yaml`. diff --git a/apps/cli-go/api/README.md b/apps/cli-go/api/README.md deleted file mode 100644 index 326d08315b..0000000000 --- a/apps/cli-go/api/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Supabase OpenAPI Specification - -This directory contains the OpenAPI specification for Supabase Management APIs. - -`v1-openapi.yaml` is a committed snapshot of the upstream specification and is the input -`go generate` uses to build the Go [client](../pkg/api/client.gen.go) and -[types](../pkg/api/types.gen.go). `overlay.yaml` patches the specification on its way -through codegen, for shapes `oapi-codegen` cannot consume directly. - -Committing the snapshot keeps codegen reproducible: `go generate` needs no network -access, and CI's `Codegen` check fails only when a pull request leaves `pkg/api` out of -sync with the snapshot. Upstream changes arrive through the -[API Sync workflow](../../../.github/workflows/cli-go-api-sync.yml), which refreshes the -snapshot and regenerates the client together in one pull request, so a spec change no -longer breaks unrelated pull requests. - -## Updating the specification - -The specification is generated from our NestJS middleware. `v1-openapi.yaml` is -refreshed from **staging** (`https://api.supabase.green/api/v1-yaml`) by the API -Sync workflow, so staging is the snapshot's authoritative upstream and the Go -client tracks the API as staging exposes it. The production release is viewable -as [Swagger UI](https://api.supabase.com/api/v1), which is useful for reading the -shipped API but is not what the snapshot is generated from -- expect it to lag -the snapshot. - -Refreshing the snapshot by hand is rarely necessary, since API Sync does it -automatically. To pick up a change from local development before it reaches staging: - -1. Update the snapshot - -```bash -curl -fsSL -o api/v1-openapi.yaml http://127.0.0.1:8080/api/v1-yaml -``` - -2. Regenerate the Go client and API types - -```bash -go generate -``` - -3. [Optional] Add [properties](https://swagger.io/docs/specification/basic-structure/) that NestJS does not generate to `overlay.yaml` diff --git a/apps/cli-go/api/overlay.yaml b/apps/cli-go/api/overlay.yaml deleted file mode 100644 index 025cca4465..0000000000 --- a/apps/cli-go/api/overlay.yaml +++ /dev/null @@ -1,79 +0,0 @@ -overlay: 1.0.0 -info: - title: Overlay - version: 0.0.0 -actions: -- target: $.components.schemas.*.properties.password_required_characters.enum - description: Converts enum with special characters to a string - remove: true -- target: $.components.schemas.*.properties.password_required_characters - description: Optionally adds back the enum with escaped backslash and quotes - update: - enum: - - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~ - - '' -- target: $.components.schemas.*.properties.attribute_mapping.properties.keys.additionalProperties.properties.default.anyOf - description: Replaces request union type with interface for easier casting - remove: true -- target: $.components.schemas.*.properties.saml.properties.attribute_mapping.properties.keys.additionalProperties.properties.default.anyOf - description: Replaces response union type with interface for easier casting - remove: true -- target: $.components.schemas.*.properties.items.items.properties.saml.properties.attribute_mapping.properties.keys.additionalProperties.properties.default.anyOf - description: Replaces list union type with interface for easier casting - remove: true -- target: $.components.schemas.*.properties.saml.properties.name_id_format.enum - description: Converts enum with duplicated type to a string - remove: true -- target: $.components.schemas.*.properties.items.items.properties.saml.properties.name_id_format.enum - description: Converts list of enum with duplicated type to a string - remove: true -- target: $.components.schemas.*.properties.connectionString - description: Removes deprecated field that conflicts with naming convention - remove: true -- target: $.components.schemas.V1CreateProjectBody.properties.release_channel - description: Removes deprecated null-only field that oapi-codegen cannot map - remove: true -- target: $.components.schemas.V1CreateProjectBody.properties.postgres_engine - description: Removes deprecated null-only field that oapi-codegen cannot map - remove: true -- target: $.components.schemas.JitListAccessResponse_Output.properties.items.items.anyOf[0].properties.invite_id - description: Replaces null-only project user invite id with nullable UUID for oapi-codegen - update: - type: string - format: uuid - nullable: true -- target: $.components.schemas.JitListAccessResponse_Output.properties.items.items.anyOf[0].properties.expires_at - description: Replaces null-only project user invite expiry with nullable string for oapi-codegen - update: - type: string - nullable: true -- target: $.components.schemas.JitListAccessResponse_Output.properties.items.items.anyOf[1].properties.user_id - description: Replaces null-only invited user id with nullable UUID for oapi-codegen - update: - type: string - format: uuid - nullable: true -- target: $.paths.*.*.parameters[?(@.name=='branch_id_or_ref')] - update: - schema: - type: string -- target: $.paths.*.*.parameters[?(@.name=='services')].schema.anyOf - description: Removes comma-string-or-array union so the array-of-enum shape can be set directly - remove: true -- target: $.paths.*.*.parameters[?(@.name=='services')].schema - description: Replaces comma-string-or-array union with a plain array of enums for oapi-codegen - update: - type: array - items: - type: string - enum: - - auth - - db - - db_postgres_user - - pooler - - realtime - - rest - - storage - - pg_bouncer diff --git a/apps/cli-go/api/v1-openapi.yaml b/apps/cli-go/api/v1-openapi.yaml deleted file mode 100644 index f0b48a3fad..0000000000 --- a/apps/cli-go/api/v1-openapi.yaml +++ /dev/null @@ -1,16840 +0,0 @@ -openapi: 3.0.0 -paths: - /v1/branches/{branch_id_or_ref}: - get: - description: Fetches configurations of the specified database branch - operationId: v1-get-a-branch-config - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchDetailResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve database branch - security: - - bearer: [] - summary: Get database branch config - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_read - - - branching_production_read - x-oauth-scope: environment:read - patch: - description: Updates the configuration of the specified database branch - operationId: v1-update-a-branch-config - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateBranchBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update database branch - security: - - bearer: [] - summary: Update database branch config - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - delete: - description: >- - Deletes the specified database branch. By default, deletes immediately. - Use force=false to schedule deletion with 1-hour grace period (only when - soft deletion is enabled). - operationId: v1-delete-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - - name: force - required: false - in: query - description: >- - If set to false, schedule deletion with 1-hour grace period (only - when soft deletion is enabled). - schema: - example: false - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchDeleteResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete database branch - security: - - bearer: [] - summary: Delete a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_delete - - - branching_production_delete - x-oauth-scope: environment:write - /v1/branches/{branch_id_or_ref}/push: - post: - description: Pushes the specified database branch - operationId: v1-push-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BranchActionBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchUpdateResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to push database branch - security: - - bearer: [] - summary: Pushes a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - /v1/branches/{branch_id_or_ref}/merge: - post: - description: Merges the specified database branch - operationId: v1-merge-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BranchActionBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchUpdateResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to merge database branch - security: - - bearer: [] - summary: Merges a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - /v1/branches/{branch_id_or_ref}/reset: - post: - description: Resets the specified database branch - operationId: v1-reset-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BranchActionBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchUpdateResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to reset database branch - security: - - bearer: [] - summary: Resets a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - /v1/branches/{branch_id_or_ref}/restore: - post: - description: Cancels scheduled deletion and restores the branch to active state - operationId: v1-restore-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchRestoreResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to restore database branch - security: - - bearer: [] - summary: Restore a scheduled branch deletion - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - /v1/branches/{branch_id_or_ref}/diff: - get: - description: |- - Diffs the specified database branch - - This endpoint is currently in its **Beta** stage. - operationId: v1-diff-a-branch - parameters: - - name: branch_id_or_ref - required: true - in: path - description: Branch ref or deprecated branch ID - schema: - example: abcdefghijklmnopqrst - anyOf: - - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - deprecated: true - - name: included_schemas - required: false - in: query - schema: - example: public,auth - type: string - - name: pgdelta - required: false - in: query - description: |- - Use pg-delta instead of Migra for diffing when true. - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - responses: - '200': - content: - text/plain: - schema: - type: string - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to diff database branch - security: - - bearer: [] - summary: Diffs a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_write - - - branching_production_write - x-oauth-scope: environment:write - x-scalar-stability: experimental - /v1/projects: - get: - description: Returns a list of all projects you've previously created. - operationId: v1-list-all-projects - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/V1ProjectWithDatabaseResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: List all projects - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - projects_read - x-oauth-scope: projects:read - post: - operationId: v1-create-a-project - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1CreateProjectBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Create a project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - organization_projects_create - x-oauth-scope: projects:write - /v1/projects/available-regions: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-available-regions - parameters: - - name: organization_slug - required: true - in: query - description: Slug of your organization - schema: - example: tsrqponmlkjihgfedcba - type: string - - name: continent - required: false - in: query - description: >- - Continent code to determine regional recommendations: NA (North - America), SA (South America), EU (Europe), AF (Africa), AS (Asia), - OC (Oceania), AN (Antarctica) - schema: - example: NA - type: string - enum: - - NA - - SA - - EU - - AF - - AS - - OC - - AN - - name: desired_instance_size - required: false - in: query - description: >- - Desired instance size. Omit this field to always default to the - smallest possible size. - schema: - type: string - enum: - - nano - - micro - - small - - medium - - large - - xlarge - - 2xlarge - - 4xlarge - - 8xlarge - - 12xlarge - - 16xlarge - - 24xlarge - - 24xlarge_optimized_memory - - 24xlarge_optimized_cpu - - 24xlarge_high_memory - - 48xlarge - - 48xlarge_optimized_memory - - 48xlarge_optimized_cpu - - 48xlarge_high_memory - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/RegionsInfoV1_Output' - security: - - bearer: [] - summary: Gets the list of available regions that can be used for a new project - tags: - - Projects - x-badges: - - name: 'OAuth scope: organizations:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - x-oauth-scope: organizations:read - x-scalar-stability: experimental - /v1/organizations: - get: - description: Returns a list of organizations that you currently belong to. - operationId: v1-list-all-organizations - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/OrganizationResponseV1_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Unexpected error listing organizations - security: - - bearer: [] - summary: List all organizations - tags: - - Organizations - x-badges: - - name: 'OAuth scope: organizations:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organizations_read - x-oauth-scope: organizations:read - post: - operationId: v1-create-an-organization - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateOrganizationV1' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationResponseV1_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Unexpected error creating an organization - security: - - bearer: [] - summary: Create an organization - tags: - - Organizations - x-endpoint-owners: - - control-plane - - billing - x-fga-permissions: - - - organizations_create - /v1/oauth/authorize: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-authorize-user - parameters: - - name: client_id - required: true - in: query - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 66666666-6666-4666-8666-666666666666 - type: string - - name: response_type - required: true - in: query - schema: - example: code - type: string - enum: - - code - - token - - id_token token - - name: redirect_uri - required: true - in: query - schema: - example: https://app.acme.com/auth/callback - type: string - - name: scope - required: false - in: query - schema: - example: projects:read projects:write - type: string - - name: state - required: false - in: query - schema: - example: st_9f4d3a206b2e4a7e8c91 - type: string - - name: response_mode - required: false - in: query - schema: - example: query - type: string - - name: code_challenge - required: false - in: query - schema: - example: Z_P4EKbGwIkA01e3Y5fp4tMCvn_Ae5nUw7qY7XwkTrQ - type: string - - name: code_challenge_method - required: false - in: query - schema: - example: S256 - type: string - enum: - - plain - - sha256 - - S256 - - name: organization_slug - required: false - in: query - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - - name: target_flow - required: false - in: query - schema: - type: string - - name: resource - required: false - in: query - description: Resource indicator for MCP (Model Context Protocol) clients - schema: - format: uri - example: https://mcp.supabase.com/projects - type: string - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - summary: Authorize user through oauth - tags: - - OAuth - x-badges: - - name: Beta - position: before - x-endpoint-owners: - - auth - - control-plane - x-scalar-stability: experimental - /v1/oauth/token: - post: - description: >- - Supports `authorization_code`, `refresh_token`, and - `urn:ietf:params:oauth:grant-type:jwt-bearer` grant types. The - `jwt-bearer` grant type (IDJAG — identity-directed JWT assertion) is in - beta and available on Team and Enterprise plans only. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-exchange-oauth-token - parameters: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/OAuthTokenBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/OAuthTokenResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - summary: Exchange auth code for user's access and refresh token - tags: - - OAuth - x-badges: - - name: Beta - position: before - x-endpoint-owners: - - auth - - control-plane - x-scalar-stability: experimental - /v1/oauth/revoke: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-revoke-token - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/OAuthRevokeTokenBody' - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - summary: Revoke oauth app authorization and it's corresponding tokens - tags: - - OAuth - x-badges: - - name: Beta - position: before - x-endpoint-owners: - - auth - - control-plane - x-scalar-stability: experimental - /v1/oauth/authorize/project-claim: - get: - description: >- - Initiates the OAuth authorization flow for the specified provider. After - successful authentication, the user can claim ownership of the specified - project. - operationId: v1-oauth-authorize-project-claim - parameters: - - name: project_ref - required: true - in: query - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: client_id - required: true - in: query - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 66666666-6666-4666-8666-666666666666 - type: string - - name: response_type - required: true - in: query - schema: - example: code - type: string - enum: - - code - - token - - id_token token - - name: redirect_uri - required: true - in: query - schema: - example: https://app.acme.com/auth/callback - type: string - - name: state - required: false - in: query - schema: - example: st_9f4d3a206b2e4a7e8c91 - type: string - - name: response_mode - required: false - in: query - schema: - example: query - type: string - - name: code_challenge - required: false - in: query - schema: - example: Z_P4EKbGwIkA01e3Y5fp4tMCvn_Ae5nUw7qY7XwkTrQ - type: string - - name: code_challenge_method - required: false - in: query - schema: - example: S256 - type: string - enum: - - plain - - sha256 - - S256 - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Authorize user through oauth and claim a project - tags: - - OAuth - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_write - - project_admin_write - /v1/snippets: - get: - operationId: v1-list-all-snippets - parameters: - - name: project_ref - required: false - in: query - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: cursor - required: false - in: query - schema: - type: string - - name: limit - required: false - in: query - schema: - type: string - minimum: 1 - maximum: 100 - - name: sort_by - required: false - in: query - schema: - enum: - - name - - inserted_at - type: string - - name: sort_order - required: false - in: query - schema: - enum: - - asc - - desc - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SnippetList_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list user's SQL snippets - security: - - bearer: [] - summary: Lists SQL snippets for the logged in user - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - snippets_read - x-oauth-scope: database:read - /v1/snippets/{id}: - get: - operationId: v1-get-a-snippet - parameters: - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 44444444-4444-4444-8444-444444444444 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SnippetResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve SQL snippet - security: - - bearer: [] - summary: Gets a specific SQL snippet - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - snippets_read - x-oauth-scope: database:read - /v1/profile: - get: - operationId: v1-get-profile - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProfileResponse_Output' - security: - - bearer: [] - summary: Gets the user's profile - tags: - - Profile - x-endpoint-owners: - - control-plane - /v1/projects/{ref}/actions: - head: - description: Returns the total number of action runs of the specified project. - operationId: v1-count-action-runs - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - headers: - X-Total-Count: - schema: - type: integer - format: int64 - minimum: 0 - description: total count value - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to count action runs - security: - - bearer: [] - summary: Count the number of action runs - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-fga-permissions: - - - action_runs_read - x-oauth-scope: environment:read - get: - description: Returns a paginated list of action runs of the specified project. - operationId: v1-list-action-runs - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: offset - required: false - in: query - schema: - minimum: 0 - example: 0 - type: number - - name: limit - required: false - in: query - schema: - minimum: 10 - example: 20 - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ListActionRunResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list action runs - security: - - bearer: [] - summary: List all action runs - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - action_runs_read - x-oauth-scope: environment:read - /v1/projects/{ref}/actions/{run_id}: - get: - description: Returns the current status of the specified action run. - operationId: v1-get-action-run - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: run_id - required: true - in: path - description: Action Run ID - schema: - example: run_01hq3q9m7y5q7e4a7x2c8m1p4n - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ActionRunResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get action run status - security: - - bearer: [] - summary: Get the status of an action run - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - action_runs_read - x-oauth-scope: environment:read - /v1/projects/{ref}/actions/{run_id}/status: - patch: - description: Updates the status of an ongoing action run. - operationId: v1-update-action-run-status - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: run_id - required: true - in: path - description: Action Run ID - schema: - example: run_01hq3q9m7y5q7e4a7x2c8m1p4n - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateRunStatusBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateRunStatusResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update action run status - security: - - bearer: [] - summary: Update the status of an action run - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - action_runs_write - x-oauth-scope: environment:write - /v1/projects/{ref}/actions/{run_id}/logs: - get: - description: Returns the logs from the specified action run. - operationId: v1-get-action-run-logs - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: run_id - required: true - in: path - description: Action Run ID - schema: - example: run_01hq3q9m7y5q7e4a7x2c8m1p4n - type: string - responses: - '200': - content: - text/plain: - schema: - type: string - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get action run logs - security: - - bearer: [] - summary: Get the logs of an action run - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - action_runs_read - x-oauth-scope: environment:read - /v1/projects/{ref}/api-keys: - get: - operationId: v1-get-project-api-keys - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: reveal - required: false - in: query - description: |- - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ApiKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Get project api keys - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_read - - - api_gateway_keys_read - - api_gateway_keys_secret_read - x-oauth-scope: secrets:read - post: - operationId: v1-create-project-api-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: reveal - required: false - in: query - description: |- - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateApiKeyBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Creates a new API key for the project - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_write - x-oauth-scope: secrets:write - /v1/projects/{ref}/api-keys/legacy: - get: - operationId: v1-get-project-legacy-api-keys - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/LegacyApiKeysResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: >- - Check whether JWT based legacy (anon, service_role) API keys are - enabled. This API endpoint will be removed in the future, check for HTTP - 404 Not Found. - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_read - x-oauth-scope: secrets:read - put: - operationId: v1-update-project-legacy-api-keys - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: enabled - required: true - in: query - description: |- - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/LegacyApiKeysResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: >- - Disable or re-enable JWT based legacy (anon, service_role) API keys. - This API endpoint will be removed in the future, check for HTTP 404 Not - Found. - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_write - x-oauth-scope: secrets:write - /v1/projects/{ref}/api-keys/{id}: - patch: - operationId: v1-update-project-api-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 22222222-2222-4222-8222-222222222222 - type: string - - name: reveal - required: false - in: query - description: |- - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateApiKeyBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Updates an API key for the project - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_write - x-oauth-scope: secrets:write - get: - operationId: v1-get-project-api-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 22222222-2222-4222-8222-222222222222 - type: string - - name: reveal - required: false - in: query - description: |- - Boolean string. - - Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - - Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - schema: - example: 'true' - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Get API key - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_read - - - api_gateway_keys_read - - api_gateway_keys_secret_read - x-oauth-scope: secrets:read - delete: - operationId: v1-delete-project-api-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 22222222-2222-4222-8222-222222222222 - type: string - - name: reveal - required: false - in: query - description: Boolean string, true or false - schema: - example: true - type: string - - name: was_compromised - required: false - in: query - description: Boolean string, true or false - schema: - example: false - type: string - - name: reason - required: false - in: query - schema: - example: rotating_key - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Deletes an API key for the project - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - - control-plane - x-fga-permissions: - - - api_gateway_keys_write - x-oauth-scope: secrets:write - /v1/projects/{ref}/branches: - get: - description: Returns all database branches of the specified project. - operationId: v1-list-all-branches - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/BranchResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve database branches - security: - - bearer: [] - summary: List all database branches - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_read - - - branching_production_read - x-oauth-scope: environment:read - post: - description: Creates a database branch from the specified project. - operationId: v1-create-a-branch - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateBranchBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to create database branch - security: - - bearer: [] - summary: Create a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_create - - - branching_production_create - x-oauth-scope: environment:write - delete: - description: Disables preview branching for the specified project - operationId: v1-disable-preview-branching - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to disable preview branching - security: - - bearer: [] - summary: Disables preview branching - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:write' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_production_delete - x-oauth-scope: environment:write - /v1/projects/{ref}/branches/{name}: - get: - description: Fetches the specified database branch by its name. - operationId: v1-get-a-branch - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: name - required: true - in: path - schema: - example: preview-login-page - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BranchResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to fetch database branch - security: - - bearer: [] - summary: Get a database branch - tags: - - Environments - x-badges: - - name: 'OAuth scope: environment:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - branching_development_read - - - branching_production_read - x-oauth-scope: environment:read - /v1/projects/{ref}/custom-hostname: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-hostname-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateCustomHostnameResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's custom hostname config - security: - - bearer: [] - summary: Gets project's custom hostname config - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - custom_domain_read - x-oauth-scope: domains:read - x-scalar-stability: experimental - delete: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-Delete hostname config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: remove_addon - required: false - in: query - description: >- - If true, also removes the custom domain add-on from the project - subscription. - schema: - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete project custom hostname configuration - security: - - bearer: [] - summary: Deletes a project's custom hostname configuration - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - custom_domain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/custom-hostname/initialize: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-update-hostname-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateCustomHostnameBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateCustomHostnameResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project custom hostname configuration - security: - - bearer: [] - summary: Updates project's custom hostname configuration - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - custom_domain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/custom-hostname/reverify: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-verify-dns-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateCustomHostnameResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to verify project custom hostname configuration - security: - - bearer: [] - summary: >- - Attempts to verify the DNS configuration for project's custom hostname - configuration - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - custom_domain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/custom-hostname/activate: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-activate-custom-hostname - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateCustomHostnameResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to activate project custom hostname configuration - security: - - bearer: [] - summary: Activates a custom hostname for a project. - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - custom_domain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/jit-access: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-jit-access-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $schema: https://json-schema.org/draft/2020-12/schema - oneOf: - - type: object - properties: - state: - type: string - enum: - - enabled - - disabled - appliedSuccessfully: - type: boolean - required: - - state - - type: object - properties: - state: - type: string - const: unavailable - unavailableReason: - type: string - enum: - - platform_unsupported - - postgres_upgrade_required - - ssl_enforcement_required - - temporarily_unavailable - required: - - state - - unavailableReason - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's temporary access configuration. - security: - - bearer: [] - summary: Get project's temporary access configuration. - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - security - - control-plane - x-fga-permissions: - - - project_admin_read - x-oauth-scope: database:read - x-scalar-stability: experimental - put: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-update-jit-access-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/JitAccessRequestRequest' - responses: - '200': - description: '' - content: - application/json: - schema: - $schema: https://json-schema.org/draft/2020-12/schema - oneOf: - - type: object - properties: - state: - type: string - enum: - - enabled - - disabled - appliedSuccessfully: - type: boolean - required: - - state - - type: object - properties: - state: - type: string - const: unavailable - unavailableReason: - type: string - enum: - - platform_unsupported - - postgres_upgrade_required - - ssl_enforcement_required - - temporarily_unavailable - required: - - state - - unavailableReason - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's temporary access configuration. - security: - - bearer: [] - summary: Update project's temporary access configuration. - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - security - - control-plane - x-fga-permissions: - - - project_admin_write - x-oauth-scope: database:write - x-scalar-stability: experimental - /v1/projects/{ref}/network-bans/retrieve: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-list-all-network-bans - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkBanResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's network bans - security: - - bearer: [] - summary: Gets project's network bans - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - database_network_bans_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - /v1/projects/{ref}/network-bans/retrieve/enriched: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-list-all-network-bans-enriched - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkBanResponseEnriched_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's enriched network bans - security: - - bearer: [] - summary: >- - Gets project's network bans with additional information about which - databases they affect - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - database_network_bans_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - /v1/projects/{ref}/network-bans: - delete: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-delete-network-bans - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RemoveNetworkBanRequest' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to remove network bans. - security: - - bearer: [] - summary: Remove network bans. - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - database_network_bans_write - x-oauth-scope: projects:write - x-scalar-stability: experimental - /v1/projects/{ref}/network-restrictions: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-network-restrictions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkRestrictionsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's network restrictions - security: - - bearer: [] - summary: Gets project's network restrictions - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_network_restrictions_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - patch: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Alpha** stage. - operationId: v1-patch-network-restrictions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkRestrictionsPatchRequest' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkRestrictionsV2Response_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project network restrictions - security: - - bearer: [] - summary: Updates project's network restrictions by adding or removing CIDRs - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - - name: Alpha - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_network_restrictions_write - x-oauth-scope: projects:write - x-scalar-stability: experimental - /v1/projects/{ref}/network-restrictions/apply: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-update-network-restrictions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkRestrictionsRequest' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkRestrictionsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project network restrictions - security: - - bearer: [] - summary: Updates project's network restrictions - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_network_restrictions_write - x-oauth-scope: projects:write - x-scalar-stability: experimental - /v1/projects/{ref}/pgsodium: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-pgsodium-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PgsodiumConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's pgsodium config - security: - - bearer: [] - summary: Gets project's pgsodium config - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - x-fga-permissions: - - - project_admin_write - x-oauth-scope: secrets:read - x-scalar-stability: experimental - put: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-update-pgsodium-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePgsodiumConfigBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PgsodiumConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's pgsodium config - security: - - bearer: [] - summary: >- - Updates project's pgsodium config. Updating the root_key can cause all - data encrypted with the older key to become inaccessible. - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - x-fga-permissions: - - - project_admin_write - x-oauth-scope: secrets:write - x-scalar-stability: experimental - /v1/projects/{ref}/postgrest: - get: - operationId: v1-get-postgrest-service-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PostgrestConfigWithJWTSecretResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's postgrest config - security: - - bearer: [] - summary: Gets project's postgrest config - tags: - - Rest - x-badges: - - name: 'OAuth scope: rest:read' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - data_api_config_read - - - data_api_config_read - - data_api_config_secret_read - x-oauth-scope: rest:read - patch: - operationId: v1-update-postgrest-service-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpdatePostgrestConfigBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1PostgrestConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's postgrest config - security: - - bearer: [] - summary: Updates project's postgrest config - tags: - - Rest - x-badges: - - name: 'OAuth scope: rest:write' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - data_api_config_write - x-oauth-scope: rest:write - /v1/projects/{ref}: - get: - operationId: v1-get-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectWithDatabaseResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project - security: - - bearer: [] - summary: Gets a specific project that belongs to the authenticated user - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - project_admin_read - x-oauth-scope: projects:read - delete: - operationId: v1-delete-a-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectRefResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Deletes the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - control-plane - - infra - - dev-workflows - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - patch: - operationId: v1-update-a-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpdateProjectBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectRefResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project - security: - - bearer: [] - summary: Updates the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - /v1/projects/{ref}/secrets: - get: - description: Returns all secrets you've previously added to the specified project. - operationId: v1-list-all-secrets - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/SecretResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's secrets - security: - - bearer: [] - summary: List all secrets - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_secrets_read - x-oauth-scope: secrets:read - post: - description: Creates multiple secrets and adds them to the specified project. - operationId: v1-bulk-create-secrets - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSecretBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to create project's secrets - security: - - bearer: [] - summary: Bulk create secrets - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_secrets_write - x-oauth-scope: secrets:write - delete: - description: Deletes all secrets with the given names from the specified project - operationId: v1-bulk-delete-secrets - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteSecretsBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete secrets with given names - security: - - bearer: [] - summary: Bulk delete secrets - tags: - - Secrets - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_secrets_write - x-oauth-scope: secrets:write - /v1/projects/{ref}/ssl-enforcement: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-ssl-enforcement-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SslEnforcementResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's SSL enforcement config - security: - - bearer: [] - summary: Get project's SSL enforcement configuration. - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_ssl_config_read - x-oauth-scope: database:read - x-scalar-stability: experimental - put: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-update-ssl-enforcement-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SslEnforcementRequest' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SslEnforcementResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's SSL enforcement configuration. - security: - - bearer: [] - summary: Update project's SSL enforcement configuration. - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_ssl_config_write - x-oauth-scope: database:write - x-scalar-stability: experimental - /v1/projects/{ref}/types/typescript: - get: - description: Returns the TypeScript types of your schema for use with supabase-js. - operationId: v1-generate-typescript-types - parameters: - - name: included_schemas - required: false - in: query - schema: - default: public - example: public,auth - type: string - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/TypescriptResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to generate TypeScript types - security: - - bearer: [] - summary: Generate TypeScript types - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - database_read - x-oauth-scope: database:read - /v1/projects/{ref}/vanity-subdomain: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-vanity-subdomain-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/VanitySubdomainConfigResponse_Output' - '400': - description: >- - This feature requires the Pro, Team, or Enterprise organization - plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project vanity subdomain configuration - security: - - bearer: [] - summary: Gets current vanity subdomain config - tags: - - Domains - x-allowed-plans: - - Pro - - Team - - Enterprise - x-badges: - - name: 'OAuth scope: domains:read' - position: after - - name: Beta - position: before - - name: Only available on Pro, Team, Enterprise - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - vanity_subdomain_read - x-oauth-scope: domains:read - x-scalar-stability: experimental - delete: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-deactivate-vanity-subdomain-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete project vanity subdomain configuration - security: - - bearer: [] - summary: Deletes a project's vanity subdomain configuration - tags: - - Domains - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - vanity_subdomain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/vanity-subdomain/check-availability: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-check-vanity-subdomain-availability - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/VanitySubdomainBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SubdomainAvailabilityResponse_Output' - '400': - description: >- - This feature requires the Pro, Team, or Enterprise organization - plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to check project vanity subdomain configuration - security: - - bearer: [] - summary: Checks vanity subdomain availability - tags: - - Domains - x-allowed-plans: - - Pro - - Team - - Enterprise - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - - name: Only available on Pro, Team, Enterprise - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - vanity_subdomain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/vanity-subdomain/activate: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-activate-vanity-subdomain-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/VanitySubdomainBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ActivateVanitySubdomainResponse_Output' - '400': - description: >- - This feature requires the Pro, Team, or Enterprise organization - plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to activate project vanity subdomain configuration - security: - - bearer: [] - summary: Activates a vanity subdomain for a project. - tags: - - Domains - x-allowed-plans: - - Pro - - Team - - Enterprise - x-badges: - - name: 'OAuth scope: domains:write' - position: after - - name: Beta - position: before - - name: Only available on Pro, Team, Enterprise - position: before - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - vanity_subdomain_write - x-oauth-scope: domains:write - x-scalar-stability: experimental - /v1/projects/{ref}/upgrade: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-upgrade-postgres-version - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpgradeDatabaseBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ProjectUpgradeInitiateResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to initiate project upgrade - security: - - bearer: [] - summary: Upgrades the project's Postgres version - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_write - - database_write - x-oauth-scope: projects:write - x-scalar-stability: experimental - /v1/projects/{ref}/upgrade/eligibility: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-postgres-upgrade-eligibility - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ProjectUpgradeEligibilityResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to determine project upgrade eligibility - security: - - bearer: [] - summary: Returns the project's eligibility for upgrades - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_read - - database_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - /v1/projects/{ref}/upgrade/status: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-get-postgres-upgrade-status - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: tracking_id - required: false - in: query - schema: - example: 9f4d3a20-6b2e-4a7e-8c91-1d5f3e7a2b4c - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DatabaseUpgradeStatusResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project upgrade status - security: - - bearer: [] - summary: Gets the latest status of the project's upgrade - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_read - - database_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - /v1/projects/{ref}/readonly: - get: - operationId: v1-get-readonly-mode-status - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ReadOnlyStatusResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project readonly mode status - security: - - bearer: [] - summary: Returns project's readonly mode status - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - - infra - - support-tooling - x-fga-permissions: - - - database_readonly_config_read - x-oauth-scope: database:read - /v1/projects/{ref}/readonly/temporary-disable: - post: - operationId: v1-disable-readonly-mode-temporarily - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to disable project's readonly mode - security: - - bearer: [] - summary: Disables project's readonly mode for the next 15 minutes - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - control-plane - - infra - - support-tooling - x-fga-permissions: - - - database_readonly_config_write - x-oauth-scope: database:write - /v1/projects/{ref}/read-replicas/setup: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-setup-a-read-replica - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SetUpReadReplicaBody' - responses: - '204': - description: '' - '401': - description: Unauthorized - '402': - description: >- - This feature requires the Pro, Team, or Enterprise organization - plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to set up read replica - security: - - bearer: [] - summary: Set up a read replica - tags: - - Database - x-allowed-plans: - - Pro - - Team - - Enterprise - x-badges: - - name: Beta - position: before - - name: Only available on Pro, Team, Enterprise - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_read_replicas_write - x-scalar-stability: experimental - /v1/projects/{ref}/read-replicas/remove: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-remove-a-read-replica - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RemoveReadReplicaBody' - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to remove read replica - security: - - bearer: [] - summary: Remove a read replica - tags: - - Database - x-badges: - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_read_replicas_write - x-scalar-stability: experimental - /v1/projects/{ref}/health: - get: - operationId: v1-get-services-health - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: services - required: true - in: query - description: Comma-separated list of enums or array of enums. - schema: - example: - - auth,db - - auth - anyOf: - - type: string - description: |- - Comma-separated list of enums: - - - `auth` - - `db` - - `db_postgres_user` - - `pooler` - - `realtime` - - `rest` - - `storage` - - `pg_bouncer` - example: - - auth,db - - auth - - type: array - items: - type: string - enum: - - auth - - db - - db_postgres_user - - pooler - - realtime - - rest - - storage - - pg_bouncer - description: Array of enums. - example: - - '{field}=auth&{field}=db' - - '{field}=auth' - - name: timeout_ms - required: false - in: query - schema: - minimum: 0 - maximum: 10000 - example: 2000 - type: integer - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/V1ServiceHealthResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's service health status - security: - - bearer: [] - summary: Gets project's service health status - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_read - x-oauth-scope: projects:read - /v1/projects/{ref}/config/auth/signing-keys/legacy: - post: - operationId: v1-create-legacy-signing-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: >- - Set up the project's existing JWT secret as an in_use JWT signing key. - This endpoint will be removed in the future always check for HTTP 404 - Not Found. - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_write - x-oauth-scope: secrets:write - get: - operationId: v1-get-legacy-signing-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: >- - Get the signing key information for the JWT secret imported as signing - key for this project. This endpoint will be removed in the future, check - for HTTP 404 Not Found. - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_read - x-oauth-scope: secrets:read - /v1/projects/{ref}/config/auth/signing-keys: - post: - operationId: v1-create-project-signing-key - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSigningKeyBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Create a new signing key for the project in standby status - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_write - x-oauth-scope: secrets:write - get: - operationId: v1-get-project-signing-keys - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeysResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: List all signing keys for the project - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_read - x-oauth-scope: secrets:read - /v1/projects/{ref}/config/auth/signing-keys/{id}: - get: - operationId: v1-get-project-signing-key - parameters: - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 33333333-3333-4333-8333-333333333333 - type: string - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Get information about a signing key - tags: - - Auth - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_read - delete: - operationId: v1-remove-project-signing-key - parameters: - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 33333333-3333-4333-8333-333333333333 - type: string - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: >- - Remove a signing key from a project. Only possible if the key has been - in revoked status for a while. - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_write - x-oauth-scope: secrets:write - patch: - operationId: v1-update-project-signing-key - parameters: - - name: id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 33333333-3333-4333-8333-333333333333 - type: string - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSigningKeyBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/SigningKeyResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Update a signing key, mainly its status - tags: - - Auth - x-badges: - - name: 'OAuth scope: secrets:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_signing_keys_write - x-oauth-scope: secrets:write - /v1/projects/{ref}/config/auth: - get: - operationId: v1-get-auth-service-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's auth config - security: - - bearer: [] - summary: Gets project's auth config - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_read - x-oauth-scope: auth:read - patch: - operationId: v1-update-auth-service-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAuthConfigBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's auth config - security: - - bearer: [] - summary: Updates a project's auth config - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - - project_admin_write - x-oauth-scope: auth:write - /v1/projects/{ref}/config/auth/third-party-auth: - post: - operationId: v1-create-project-tpa-integration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateThirdPartyAuthBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ThirdPartyAuth_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Creates a new third-party auth integration - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - x-oauth-scope: auth:write - get: - operationId: v1-list-project-tpa-integrations - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ThirdPartyAuth_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Lists all third-party auth integrations - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_read - x-oauth-scope: auth:read - /v1/projects/{ref}/config/auth/third-party-auth/{tpa_id}: - delete: - operationId: v1-delete-project-tpa-integration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: tpa_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 88888888-8888-4888-8888-888888888888 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ThirdPartyAuth_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Removes a third-party auth integration - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - x-oauth-scope: auth:write - get: - operationId: v1-get-project-tpa-integration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: tpa_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 88888888-8888-4888-8888-888888888888 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ThirdPartyAuth_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Get a third-party integration - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_read - x-oauth-scope: auth:read - /v1/projects/{ref}/pause: - post: - operationId: v1-pause-a-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Pauses the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - /v1/projects/{ref}/restart: - post: - operationId: v1-restart-a-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Restarts the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - /v1/projects/{ref}/restore: - get: - operationId: v1-list-available-restore-versions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/GetProjectAvailableRestoreVersionsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Lists available restore versions for the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_read - x-oauth-scope: projects:read - post: - operationId: v1-restore-a-project - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Restores the given project - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - /v1/projects/{ref}/restore/cancel: - post: - operationId: v1-cancel-a-project-restoration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Cancels the given project restoration - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:write' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - project_admin_write - x-oauth-scope: projects:write - /v1/projects/{ref}/billing/addons: - get: - description: >- - Returns the billing addons that are currently applied, including the - active compute instance size, and lists every addon option that can be - provisioned with pricing metadata. - operationId: v1-list-project-addons - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ListProjectAddonsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list project addons - security: - - bearer: [] - summary: List billing addons and compute instance selections - tags: - - Billing - x-endpoint-owners: - - billing - x-fga-permissions: - - - infra_add_ons_read - patch: - description: >- - Selects an addon variant, for example scaling the project’s compute - instance up or down, and applies it to the project. - operationId: v1-apply-project-addon - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ApplyProjectAddonBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to apply project addon - security: - - bearer: [] - summary: Apply or update billing addons, including compute instance size - tags: - - Billing - x-endpoint-owners: - - billing - x-fga-permissions: - - - infra_add_ons_write - /v1/projects/{ref}/billing/addons/{addon_variant}: - delete: - description: >- - Disables the selected addon variant, including rolling the compute - instance back to its previous size. - operationId: v1-remove-project-addon - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: addon_variant - required: true - in: path - schema: - example: pitr_7 - anyOf: - - type: string - enum: - - ci_micro - - ci_small - - ci_medium - - ci_large - - ci_xlarge - - ci_2xlarge - - ci_4xlarge - - ci_8xlarge - - ci_12xlarge - - ci_16xlarge - - ci_24xlarge - - ci_24xlarge_optimized_cpu - - ci_24xlarge_optimized_memory - - ci_24xlarge_high_memory - - ci_48xlarge - - ci_48xlarge_optimized_cpu - - ci_48xlarge_optimized_memory - - ci_48xlarge_high_memory - - type: string - enum: - - cd_default - - type: string - enum: - - pitr_7 - - pitr_14 - - pitr_28 - - type: string - enum: - - ipv4_default - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to remove project addon - security: - - bearer: [] - summary: Remove billing addons or revert compute instance sizing - tags: - - Billing - x-endpoint-owners: - - billing - x-fga-permissions: - - - infra_add_ons_write - /v1/projects/{ref}/claim-token: - get: - operationId: v1-get-project-claim-token - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ProjectClaimTokenResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets project claim token - tags: - - Projects - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - project_admin_read - x-internal: true - post: - operationId: v1-create-project-claim-token - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/CreateProjectClaimTokenResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Creates project claim token - tags: - - Projects - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_write - - project_admin_write - x-internal: true - delete: - operationId: v1-delete-project-claim-token - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Revokes project claim token - tags: - - Projects - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_write - - project_admin_write - x-internal: true - /v1/projects/{ref}/advisors/performance: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - operationId: v1-get-performance-advisors - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectAdvisorsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets project performance advisors. - tags: - - Advisors - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - advisors_read - x-oauth-scope: database:read - x-scalar-stability: experimental - /v1/projects/{ref}/advisors/security: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - operationId: v1-get-security-advisors - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: lint_type - required: false - in: query - schema: - example: sql - type: string - enum: - - sql - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ProjectAdvisorsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets project security advisors. - tags: - - Advisors - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - advisors_read - x-oauth-scope: database:read - x-scalar-stability: experimental - /v1/projects/{ref}/analytics/endpoints/logs.all: - get: - deprecated: true - description: >- - This endpoint has been removed and always responds with `410 Gone`. Use - `GET /v1/projects/{ref}/analytics/endpoints/logs` instead. See the - [migration - guide](https://supabase.com/changelog/48235-migration-of-supabase-management-api-logs-all-analytics-endpoint-to-logs-endpoint). - operationId: v1-get-project-logs-all - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '401': - description: Unauthorized - '403': - description: Forbidden action - '410': - description: This endpoint has been removed - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets project's logs - tags: - - Analytics - x-badges: - - name: 'OAuth scope: analytics:read' - position: after - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_logs_read - x-oauth-scope: analytics:read - /v1/projects/{ref}/analytics/endpoints/logs: - get: - deprecated: false - description: > - Executes an SQL or LQL query on the project's unified logs stream. - - - Either the `iso_timestamp_start` and `iso_timestamp_end` parameters must - be provided. - - If both are not provided, only the last 1 minute of logs will be - queried. - - The timestamp range must be no more than 24 hours and is rounded to the - nearest minute. If the range is more than 24 hours, a validation error - will be thrown. - - - Filter by the `source` column to specify specific log sources, such as - edge_logs, postgres_logs, etc. - - - Note: SQL must be written in **ClickHouse SQL dialect**. - operationId: v1-get-project-logs - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: sql - required: false - in: query - description: >- - Custom SQL query to execute on the logs. See [querying - logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) - for more details. - schema: - example: select event_message from edge_logs limit 10 - type: string - - name: iso_timestamp_start - required: false - in: query - schema: - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ - example: '2025-03-01T00:00:00Z' - type: string - - name: iso_timestamp_end - required: false - in: query - schema: - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ - example: '2025-03-01T23:59:59Z' - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AnalyticsResponse_Output' - '401': - description: Unauthorized - '402': - description: Usage exceeded. Enable additional usage to continue querying - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets all project's logs in a single log stream - tags: - - Analytics - x-badges: - - name: 'OAuth scope: analytics:read' - position: after - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_logs_read - x-oauth-scope: analytics:read - /v1/projects/{ref}/analytics/endpoints/usage.api-counts: - get: - operationId: v1-get-project-usage-api-count - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: interval - required: false - in: query - schema: - example: 1day - type: string - enum: - - 15min - - 30min - - 1hr - - 3hr - - 1day - - 3day - - 7day - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1GetUsageApiCountResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project's usage api counts - security: - - bearer: [] - summary: Gets project's usage api counts - tags: - - Analytics - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_usage_read - /v1/projects/{ref}/analytics/endpoints/usage.api-requests-count: - get: - operationId: v1-get-project-usage-request-count - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1GetUsageApiRequestsCountResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project's usage api requests count - security: - - bearer: [] - summary: Gets project's usage api requests count - tags: - - Analytics - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_usage_read - /v1/projects/{ref}/analytics/endpoints/functions.combined-stats: - get: - operationId: v1-get-project-function-combined-stats - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: interval - required: true - in: query - schema: - example: 1hr - type: string - enum: - - 15min - - 1hr - - 3hr - - 1day - - name: function_id - required: true - in: query - schema: - example: 3c078cce-ad70-4148-9f37-4da362789053 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AnalyticsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project's function combined statistics - security: - - bearer: [] - summary: Gets a project's function combined statistics - tags: - - Analytics - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_usage_read - /v1/projects/{ref}/analytics/endpoints/metrics: - get: - description: >- - Prometheus scrape endpoint. Returns metrics of a customer project in the - Prometheus open exposition format. - operationId: v1-scrape-project-metrics - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: Prometheus / OpenMetrics text exposition - content: - text/plain: - schema: - type: string - application/openmetrics-text: - schema: - type: string - '400': - description: >- - Project must be active and healthy, or metrics are not available for - this project - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to fetch project's metrics - security: - - bearer: [] - summary: Scrape a project's metrics - tags: - - Analytics - x-badges: - - name: 'OAuth scope: analytics:read' - position: after - x-endpoint-owners: - - observability - x-fga-permissions: - - - analytics_logs_read - x-oauth-scope: analytics:read - /v1/projects/{ref}/cli/login-role: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-create-login-role - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateRoleBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/CreateRoleResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to create login role - security: - - bearer: [] - summary: Create a login role for CLI with temporary password - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - database_write - x-oauth-scope: database:write - x-scalar-stability: experimental - delete: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-delete-login-roles - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteRolesResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete login roles - security: - - bearer: [] - summary: Delete existing login roles used by CLI - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - dev-workflows - x-fga-permissions: - - - database_write - x-oauth-scope: database:write - x-scalar-stability: experimental - /v1/projects/{ref}/database/migrations: - get: - operationId: v1-list-migration-history - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ListMigrationsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list database migrations - security: - - bearer: [] - summary: List applied migration versions - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_read - x-oauth-scope: database:read - post: - operationId: v1-apply-a-migration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: Idempotency-Key - required: false - in: header - description: A unique key to ensure the same migration is tracked only once. - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1CreateMigrationBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to apply database migration - security: - - bearer: [] - summary: Apply a database migration - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_write - x-oauth-scope: database:write - put: - operationId: v1-upsert-a-migration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: Idempotency-Key - required: false - in: header - description: A unique key to ensure the same migration is tracked only once. - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpsertMigrationBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to upsert database migration - security: - - bearer: [] - summary: Upsert a database migration without applying - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_write - x-oauth-scope: database:write - delete: - operationId: v1-rollback-migrations - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: gte - required: true - in: query - description: Rollback migrations greater or equal to this version - schema: - pattern: ^\d+$ - example: '20250312000000' - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to rollback database migration - security: - - bearer: [] - summary: Rollback database migrations and remove them from history table - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_write - x-oauth-scope: database:write - /v1/projects/{ref}/database/migrations/{version}: - get: - operationId: v1-get-a-migration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: version - required: true - in: path - schema: - pattern: ^\d+$ - example: '20250312000000' - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1GetMigrationResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get database migration - security: - - bearer: [] - summary: Fetch an existing entry from migration history - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_read - x-oauth-scope: database:read - patch: - operationId: v1-patch-a-migration - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: version - required: true - in: path - schema: - pattern: ^\d+$ - example: '20250312000000' - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1PatchMigrationBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to patch database migration - security: - - bearer: [] - summary: Patch an existing entry in migration history - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_migrations_write - x-oauth-scope: database:write - /v1/projects/{ref}/database/query: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-run-a-query - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1RunQueryBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to run sql query - security: - - bearer: [] - summary: Run sql query - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - database_read - - - database_write - x-oauth-scope: database:write - x-scalar-stability: experimental - /v1/projects/{ref}/database/query/read-only: - post: - description: |- - All entity references must be schema qualified. - - This endpoint is currently in its **Beta** stage. - operationId: v1-read-only-query - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1ReadOnlyQueryBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to run read-only sql query - security: - - bearer: [] - summary: Run a sql query as supabase_read_only_user - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - database_read - x-oauth-scope: database:read - x-scalar-stability: experimental - /v1/projects/{ref}/database/webhooks/enable: - post: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - - - This endpoint is currently in its **Beta** stage. - operationId: v1-enable-database-webhook - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to enable Database Webhooks on the project - security: - - bearer: [] - summary: Enables Database Webhooks on the project - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Beta - position: before - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - database_webhooks_config_write - x-oauth-scope: database:write - x-scalar-stability: experimental - /v1/projects/{ref}/database/context: - get: - description: >- - This is an **experimental** endpoint. It is subject to change or removal - in future versions. Use it with caution, as it may not remain supported - or stable. - operationId: v1-get-database-metadata - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/GetProjectDbMetadataResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets database metadata for the given project. - tags: - - Database - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - database_read - x-oauth-scope: projects:read - x-scalar-stability: experimental - /v1/projects/{ref}/database/password: - patch: - operationId: v1-update-database-password - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpdatePasswordBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpdatePasswordResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update database password - security: - - bearer: [] - summary: Updates the database password - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - database_config_write - x-oauth-scope: database:write - /v1/projects/{ref}/database/jit: - get: - description: Mappings of roles a user can assume in the project database - operationId: v1-get-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/JitAccessResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list database jit access - security: - - bearer: [] - summary: Get user-id to role mappings for JIT access - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_read - x-oauth-scope: database:read - post: - description: Authorizes the request to assume a role in the project database - operationId: v1-authorize-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AuthorizeJitAccessBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/JitAuthorizeAccessResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to authorize database jit access - security: - - bearer: [] - summary: Authorize user-id to role mappings for JIT access - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_read - x-oauth-scope: database:read - put: - description: Modifies the roles that can be assumed and for how long - operationId: v1-update-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateJitAccessBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/JitAccessResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update JIT access - security: - - bearer: [] - summary: Updates a user mapping for JIT access - tags: - - Database - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_write - /v1/projects/{ref}/database/jit/list: - get: - description: Mappings of roles a user can assume in the project database - operationId: v1-list-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/JitListAccessResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to list database jit access - security: - - bearer: [] - summary: List all user-id to role mappings for JIT access - tags: - - Database - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_write - /v1/projects/{ref}/database/jit/invite: - post: - description: >- - Invites the external user and sets initial roles that can be assumed and - for how long - operationId: v1-invite-external-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/InviteExternalUserJitAccessBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/InviteExternalUserJitResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to invite external user - security: - - bearer: [] - summary: Invites an external user to a database for JIT access - tags: - - Database - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_write - /v1/projects/{ref}/database/jit/invite/accept: - post: - description: Accepts the invitation to JIT database access - operationId: v1-accept-invite-external-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AcceptInviteExternalUserJitAccessBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/JitAccessResponse_Output' - '500': - description: Failed to accept invitation - security: - - bearer: [] - summary: Accepts invitation for JIT database access - tags: - - Database - x-endpoint-owners: - - security - /v1/projects/{ref}/database/jit/invite/{invite_id}: - delete: - description: Revokes and deletes the invitation - operationId: v1-delete-invite-external-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: invite_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 55555555-5555-4555-8555-555555555555 - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to revoke invite for external user - security: - - bearer: [] - summary: Deletes the invite for an external user to a database for JIT access - tags: - - Database - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_write - /v1/projects/{ref}/database/jit/{user_id}: - delete: - description: Remove JIT mappings of a user, revoking all JIT database access - operationId: v1-delete-jit-access - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: user_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 55555555-5555-4555-8555-555555555555 - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to remove JIT access - security: - - bearer: [] - summary: Delete JIT access by user-id - tags: - - Database - x-endpoint-owners: - - security - x-fga-permissions: - - - database_jit_write - /v1/projects/{ref}/database/openapi: - get: - description: >- - Returns the PostgREST OpenAPI specification for the project. This is the - replacement for querying `/rest/v1/` directly with the anon key. - operationId: v1-get-database-openapi - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: schema - required: false - in: query - description: The database schema to generate the OpenAPI spec for - schema: - default: public - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: object - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to fetch PostgREST OpenAPI spec - security: - - bearer: [] - summary: Get PostgREST OpenAPI spec - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - database_read - x-oauth-scope: database:read - /v1/projects/{ref}/functions: - get: - description: Returns all functions you've previously added to the specified project. - operationId: v1-list-all-functions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FunctionResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's functions - security: - - bearer: [] - summary: List all functions - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:read' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_read - x-oauth-scope: edge_functions:read - post: - deprecated: true - description: >- - This endpoint is deprecated - use the deploy endpoint. Creates a - function and adds it to the specified project. - operationId: v1-create-a-function - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: slug - required: false - in: query - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - - name: name - required: false - in: query - schema: - example: Hello World - type: string - - name: verify_jwt - required: false - in: query - schema: - example: true - type: string - - name: import_map - required: false - in: query - schema: - example: false - type: string - - name: entrypoint_path - required: false - in: query - schema: - example: index.ts - type: string - - name: import_map_path - required: false - in: query - schema: - example: import_map.json - type: string - - name: ezbr_sha256 - required: false - in: query - schema: - example: 44c691990518d25498f0fd80cf6631ecf2b58eb9c5eb2a087dd1688f2904dac7 - type: string - requestBody: - required: true - content: - application/vnd.denoland.eszip: - schema: - type: string - format: binary - application/json: - schema: - $ref: '#/components/schemas/V1CreateFunctionBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/FunctionResponse_Output' - '401': - description: Unauthorized - '402': - description: Maximum number of functions reached for Plan - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to create project's function - security: - - bearer: [] - summary: Create a function - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_write - x-oauth-scope: edge_functions:write - put: - description: >- - Bulk update functions. It will create a new function or replace - existing. The operation is idempotent. NOTE: You will need to manually - bump the version. - operationId: v1-bulk-update-functions - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BulkUpdateFunctionBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/BulkUpdateFunctionResponse_Output' - '401': - description: Unauthorized - '402': - description: Maximum number of functions reached for Plan - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update functions - security: - - bearer: [] - summary: Bulk update functions - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_write - x-oauth-scope: edge_functions:write - /v1/projects/{ref}/functions/deploy: - post: - description: >- - A new endpoint to deploy functions. It will create if function does not - exist. - operationId: v1-deploy-a-function - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: slug - required: false - in: query - schema: - pattern: ^[A-Za-z][A-Za-z0-9_-]*$ - example: hello-world - type: string - - name: bundleOnly - required: false - in: query - schema: - example: false - type: string - requestBody: - required: true - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/FunctionDeployBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DeployFunctionResponse_Output' - '401': - description: Unauthorized - '402': - description: Maximum number of functions reached for Plan - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to deploy function - security: - - bearer: [] - summary: Deploy a function - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_write - x-oauth-scope: edge_functions:write - /v1/projects/{ref}/functions/{function_slug}: - get: - description: Retrieves a function with the specified slug and project. - operationId: v1-get-a-function - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: function_slug - required: true - in: path - description: Function slug - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/FunctionSlugResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve function with given slug - security: - - bearer: [] - summary: Retrieve a function - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:read' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_read - x-oauth-scope: edge_functions:read - patch: - description: Updates a function with the specified slug and project. - operationId: v1-update-a-function - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: function_slug - required: true - in: path - description: Function slug - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - - name: slug - required: false - in: query - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - - name: name - required: false - in: query - schema: - example: Hello World - type: string - - name: verify_jwt - required: false - in: query - schema: - example: true - type: string - - name: import_map - required: false - in: query - schema: - example: false - type: string - - name: entrypoint_path - required: false - in: query - schema: - example: index.ts - type: string - - name: import_map_path - required: false - in: query - schema: - example: import_map.json - type: string - - name: ezbr_sha256 - required: false - in: query - schema: - example: 44c691990518d25498f0fd80cf6631ecf2b58eb9c5eb2a087dd1688f2904dac7 - type: string - requestBody: - required: true - content: - application/vnd.denoland.eszip: - schema: - $ref: '#/components/schemas/V1UpdateFunctionBodyEszip' - application/json: - schema: - $ref: '#/components/schemas/V1UpdateFunctionBodyJson' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/FunctionSlugResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update function with given slug - security: - - bearer: [] - summary: Update a function - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_write - x-oauth-scope: edge_functions:write - delete: - description: Deletes a function with the specified slug from the specified project. - operationId: v1-delete-a-function - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: function_slug - required: true - in: path - description: Function slug - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to delete function with given slug - security: - - bearer: [] - summary: Delete a function - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:write' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_write - x-oauth-scope: edge_functions:write - /v1/projects/{ref}/functions/{function_slug}/body: - get: - description: Retrieves a function body for the specified slug and project. - operationId: v1-get-a-function-body - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: function_slug - required: true - in: path - description: Function slug - schema: - pattern: ^[A-Za-z0-9_-]+$ - example: hello-world - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/StreamableFile' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve function body with given slug - security: - - bearer: [] - summary: Retrieve a function body - tags: - - Edge Functions - x-badges: - - name: 'OAuth scope: edge_functions:read' - position: after - x-endpoint-owners: - - functions - x-fga-permissions: - - - edge_functions_read - x-oauth-scope: edge_functions:read - /v1/projects/{ref}/storage/buckets: - get: - operationId: v1-list-all-buckets - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/V1StorageBucketResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get list of buckets - security: - - bearer: [] - summary: Lists all buckets - tags: - - Storage - x-badges: - - name: 'OAuth scope: storage:read' - position: after - x-endpoint-owners: - - storage - x-fga-permissions: - - - storage_read - x-oauth-scope: storage:read - /v1/projects/{ref}/config/disk: - get: - operationId: v1-get-database-disk - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DiskResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get database disk attributes - security: - - bearer: [] - summary: Get database disk attributes - tags: - - Projects - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_disk_config_read - post: - operationId: v1-modify-database-disk - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DiskRequestBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to modify database disk - security: - - bearer: [] - summary: Modify database disk - tags: - - Projects - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_disk_config_write - /v1/projects/{ref}/config/disk/util: - get: - operationId: v1-get-disk-utilization - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DiskUtilMetricsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get disk utilization - security: - - bearer: [] - summary: Get disk utilization - tags: - - Projects - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_disk_config_read - /v1/projects/{ref}/config/disk/autoscale: - get: - operationId: v1-get-project-disk-autoscale-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DiskAutoscaleConfig_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get project disk autoscale config - security: - - bearer: [] - summary: Gets project disk autoscale config - tags: - - Projects - x-endpoint-owners: - - control-plane - - infra - x-fga-permissions: - - - infra_disk_config_read - /v1/projects/{ref}/config/storage: - get: - operationId: v1-get-storage-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/StorageConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's storage config - security: - - bearer: [] - summary: Gets project's storage config - tags: - - Storage - x-endpoint-owners: - - storage - x-fga-permissions: - - - storage_config_read - patch: - operationId: v1-update-storage-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateStorageConfigBody' - responses: - '200': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's storage config - security: - - bearer: [] - summary: Updates project's storage config - tags: - - Storage - x-endpoint-owners: - - storage - x-fga-permissions: - - - storage_config_write - /v1/projects/{ref}/config/database/pgbouncer: - get: - operationId: v1-get-project-pgbouncer-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1PgbouncerConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's pgbouncer config - summary: Get project's pgbouncer config - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_read - x-oauth-scope: database:read - /v1/projects/{ref}/config/database/pooler: - get: - operationId: v1-get-pooler-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/SupavisorConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's supavisor config - security: - - bearer: [] - summary: Gets project's supavisor config - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_pooling_config_read - x-oauth-scope: database:read - patch: - operationId: v1-update-pooler-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSupavisorConfigBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSupavisorConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's supavisor config - security: - - bearer: [] - summary: Updates project's supavisor config - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - database_pooling_config_write - x-oauth-scope: database:write - /v1/projects/{ref}/config/database/postgres: - get: - operationId: v1-get-postgres-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PostgresConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve project's Postgres config - security: - - bearer: [] - summary: Gets project's Postgres config - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_config_read - x-oauth-scope: database:read - put: - operationId: v1-update-postgres-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePostgresConfigBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PostgresConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to update project's Postgres config - security: - - bearer: [] - summary: Updates project's Postgres config - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - - control-plane - x-fga-permissions: - - - database_config_write - x-oauth-scope: database:write - /v1/projects/{ref}/config/realtime: - get: - operationId: v1-get-realtime-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: Gets project's realtime configuration - content: - application/json: - schema: - $ref: '#/components/schemas/RealtimeConfigResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets realtime configuration - tags: - - Realtime - x-endpoint-owners: - - realtime - x-fga-permissions: - - - realtime_config_read - patch: - operationId: v1-update-realtime-config - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateRealtimeConfigBody' - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Updates realtime configuration - tags: - - Realtime - x-endpoint-owners: - - realtime - x-fga-permissions: - - - realtime_config_write - /v1/projects/{ref}/config/realtime/shutdown: - post: - operationId: v1-shutdown-realtime - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '204': - description: Realtime connections shutdown successfully - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: Tenant not found - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Shutdowns realtime connections for a project - tags: - - Realtime - x-endpoint-owners: - - realtime - x-fga-permissions: - - - realtime_config_write - /v1/projects/{ref}/config/auth/sso/providers: - post: - operationId: v1-create-a-sso-provider - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateProviderBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/CreateProviderResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: SAML 2.0 support is not enabled for this project - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Creates a new SSO provider - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - x-oauth-scope: auth:write - get: - operationId: v1-list-all-sso-provider - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ListProvidersResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: SAML 2.0 support is not enabled for this project - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Lists all SSO providers - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_read - x-oauth-scope: auth:read - /v1/projects/{ref}/config/auth/sso/providers/{provider_id}: - get: - operationId: v1-get-a-sso-provider - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: provider_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 77777777-7777-4777-8777-777777777777 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/GetProviderResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: >- - Either SAML 2.0 was not enabled for this project, or the provider - does not exist - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets a SSO provider by its UUID - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:read' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_read - x-oauth-scope: auth:read - put: - operationId: v1-update-a-sso-provider - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: provider_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 77777777-7777-4777-8777-777777777777 - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateProviderBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateProviderResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: >- - Either SAML 2.0 was not enabled for this project, or the provider - does not exist - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Updates a SSO provider by its UUID - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - x-oauth-scope: auth:write - delete: - operationId: v1-delete-a-sso-provider - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: provider_id - required: true - in: path - schema: - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - example: 77777777-7777-4777-8777-777777777777 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteProviderResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '404': - description: >- - Either SAML 2.0 was not enabled for this project, or the provider - does not exist - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Removes a SSO provider by its UUID - tags: - - Auth - x-badges: - - name: 'OAuth scope: auth:write' - position: after - x-endpoint-owners: - - auth - x-fga-permissions: - - - auth_config_write - x-oauth-scope: auth:write - /v1/projects/{ref}/database/backups: - get: - operationId: v1-list-all-backups - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1BackupsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get backups - security: - - bearer: [] - summary: Lists all backups - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_read - x-oauth-scope: database:read - /v1/projects/{ref}/database/backups/restore-pitr: - post: - operationId: v1-restore-pitr-backup - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1RestorePitrBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Restores a PITR backup for a database - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_write - x-oauth-scope: database:write - /v1/projects/{ref}/database/backups/restore-point: - post: - operationId: v1-create-restore-point - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1RestorePointPostBody' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1RestorePointResponse' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Initiates a creation of a restore point for a database - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_write - x-internal: true - x-oauth-scope: database:write - get: - operationId: v1-get-restore-point - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - - name: name - required: false - in: query - schema: - maxLength: 20 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1RestorePointResponse' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to get requested restore points - security: - - bearer: [] - summary: Get restore points for project - tags: - - Database - x-badges: - - name: 'OAuth scope: database:read' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_read - x-internal: true - x-oauth-scope: database:read - /v1/projects/{ref}/database/backups/restore: - post: - operationId: v1-restore-physical-backup - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1RestoreBackupBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Restores a physical backup for a database - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_write - x-internal: true - x-oauth-scope: database:write - /v1/projects/{ref}/database/backups/schedule: - get: - operationId: v1-get-backup-schedule - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1BackupScheduleResponse_Output' - '401': - description: Unauthorized - '402': - description: This feature requires the Enterprise organization plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '403': - description: Forbidden action - '404': - description: Project or backup schedule not found - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve backup schedule - security: - - bearer: [] - summary: Gets the backup schedule for a project - tags: - - Database - x-allowed-plans: - - Enterprise - x-badges: - - name: 'OAuth scope: database:read' - position: after - - name: Only available on Enterprise - position: before - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_read - x-oauth-scope: database:read - patch: - description: >- - Sets the time at which the daily backup runs. The change takes effect on - the next backup window that includes the new time. If the new time has - already passed for today, the first backup at the new time will occur - the following day. It can only be updated 3 times per 24 hours. - operationId: v1-update-backup-schedule - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UpdateBackupScheduleBody' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1BackupScheduleResponse_Output' - '400': - description: Invalid schedule_for format - '401': - description: Unauthorized - '402': - description: This feature requires the Enterprise organization plan. - content: - application/json: - schema: - $ref: '#/components/schemas/PlanGateErrorBody' - '403': - description: Forbidden action - '404': - description: Project or backup schedule not found - '429': - description: Rate limit exceeded - '500': - description: Failed to update backup schedule - security: - - bearer: [] - summary: Updates the backup schedule time for a project - tags: - - Database - x-allowed-plans: - - Enterprise - x-badges: - - name: 'OAuth scope: database:write' - position: after - - name: Only available on Enterprise - position: before - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_write - x-oauth-scope: database:write - /v1/projects/{ref}/database/backups/undo: - post: - operationId: v1-undo - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/V1UndoBody' - responses: - '201': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Initiates an undo to a given restore point - tags: - - Database - x-badges: - - name: 'OAuth scope: database:write' - position: after - x-endpoint-owners: - - infra - x-fga-permissions: - - - backups_write - x-internal: true - x-oauth-scope: database:write - /v1/organizations/{slug}/entitlements: - get: - description: >- - Returns the entitlements available to the organization based on their - plan and any overrides. - operationId: v1-get-organization-entitlements - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1ListEntitlementsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Get entitlements for an organization - tags: - - Organizations - x-badges: - - name: 'OAuth scope: organizations:read' - position: after - x-endpoint-owners: - - billing - x-fga-permissions: - - - organization_admin_read - x-oauth-scope: organizations:read - /v1/organizations/{slug}/members: - get: - operationId: v1-list-organization-members - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/V1OrganizationMemberResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: List members of an organization - tags: - - Organizations - x-badges: - - name: 'OAuth scope: organizations:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - members_read - x-oauth-scope: organizations:read - /v1/organizations/{slug}: - get: - operationId: v1-get-an-organization - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/V1OrganizationSlugResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets information about the organization - tags: - - Organizations - x-badges: - - name: 'OAuth scope: organizations:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_read - x-oauth-scope: organizations:read - /v1/organizations/{slug}/project-claim/{token}: - get: - operationId: v1-get-organization-project-claim - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - - name: token - required: true - in: path - schema: - example: 0123456789abcdef0123456789abcdef01234567 - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationProjectClaimResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Gets project details for the specified organization and claim token - tags: - - Organizations - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_write - x-internal: true - post: - operationId: v1-claim-project-for-organization - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - - name: token - required: true - in: path - schema: - example: 0123456789abcdef0123456789abcdef01234567 - type: string - responses: - '204': - description: '' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - security: - - bearer: [] - summary: Claims project for the specified organization - tags: - - Organizations - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_admin_write - x-internal: true - /v1/organizations/{slug}/projects: - get: - description: >- - Returns a paginated list of projects for the specified organization. - - - This endpoint uses offset-based pagination. Use the `offset` parameter - to skip a number of projects and the `limit` parameter to control the - number of projects returned per page. - operationId: v1-get-all-projects-for-organization - parameters: - - name: slug - required: true - in: path - description: Organization slug - schema: - pattern: ^[\w-]+$ - example: tsrqponmlkjihgfedcba - type: string - - name: offset - required: false - in: query - description: Number of projects to skip - schema: - minimum: 0 - maximum: 9007199254740991 - default: 0 - example: 0 - type: integer - - name: limit - required: false - in: query - description: Number of projects to return per page - schema: - minimum: 1 - maximum: 100 - default: 100 - example: 20 - type: integer - - name: search - required: false - in: query - description: Search projects by name - schema: - example: acme - type: string - - name: sort - required: false - in: query - description: Sort order for projects - schema: - default: name_asc - example: created_desc - type: string - enum: - - name_asc - - name_desc - - created_asc - - created_desc - - name: statuses - required: false - in: query - description: |- - A comma-separated list of project statuses to filter by. - - The following values are supported: `ACTIVE_HEALTHY`, `INACTIVE`. - schema: - example: ACTIVE_HEALTHY,INACTIVE - type: string - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/OrganizationProjectsResponse_Output' - '401': - description: Unauthorized - '403': - description: Forbidden action - '429': - description: Rate limit exceeded - '500': - description: Failed to retrieve projects - security: - - bearer: [] - summary: Gets all projects for the given organization - tags: - - Projects - x-badges: - - name: 'OAuth scope: projects:read' - position: after - x-endpoint-owners: - - control-plane - x-fga-permissions: - - - organization_projects_read - x-oauth-scope: projects:read -info: - title: Supabase API (v1) - description: >- - Supabase API generated from the OpenAPI specification.
Visit - [https://supabase.com/docs](https://supabase.com/docs) for a complete - documentation. - version: 1.0.0 - contact: {} -tags: - - name: Advisors - description: Advisors related endpoints - - name: Analytics - description: Analytics related endpoints - - name: Auth - description: Auth related endpoints - - name: Billing - description: Billing related endpoints - - name: Database - description: Database related endpoints - - name: Domains - description: Domains related endpoints - - name: Edge Functions - description: Edge related endpoints - - name: Environments - description: Environments related endpoints - - name: OAuth - description: OAuth related endpoints - - name: Organizations - description: Organizations related endpoints - - name: Projects - description: Projects related endpoints - - name: Rest - description: Rest related endpoints - - name: Secrets - description: Secrets related endpoints - - name: Storage - description: >- - Visit - [https://supabase.github.io/storage/](https://supabase.github.io/storage/) - for complete documentation. -servers: - - url: https://api.supabase.green -components: - securitySchemes: - bearer: - scheme: bearer - bearerFormat: JWT - type: http - oauth2: - type: oauth2 - flows: - authorizationCode: - authorizationUrl: https://api.supabase.green/v1/oauth/authorize - tokenUrl: https://api.supabase.green/v1/oauth/token - scopes: - analytics:read: Read access to analytics - analytics:write: Write access to analytics - analytics_config:read: Read access to analytics config - analytics_config:write: Write access to analytics config - auth:read: Read access to auth - auth:write: Write access to auth - database:read: Read access to database - database:write: Write access to database - domains:read: Read access to domains - domains:write: Write access to domains - edge_functions:read: Read access to edge functions - edge_functions:write: Write access to edge functions - environment:read: Read access to environment - environment:write: Write access to environment - organizations:read: Read access to organizations - organizations:write: Write access to organizations - projects:read: Read access to projects - projects:write: Write access to projects - rest:read: Read access to rest - rest:write: Write access to rest - secrets:read: Read access to secrets - secrets:write: Write access to secrets - storage:read: Read access to storage - storage:write: Write access to storage - description: >- - OAuth 2.0 authorization code flow for Supabase OAuth apps. Tokens carry - the scopes approved for the app by the organization. - schemas: - BranchDetailResponse_Output: - type: object - properties: - ref: - type: string - postgres_version: - type: string - postgres_engine: - type: string - release_channel: - type: string - status: - type: string - enum: - - INACTIVE - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - UNKNOWN - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UPGRADING - - PAUSING - - RESTORE_FAILED - - RESTARTING - - PAUSE_FAILED - - RESIZING - db_host: - type: string - db_port: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - db_user: - type: string - db_pass: - type: string - jwt_secret: - type: string - required: - - ref - - postgres_version - - postgres_engine - - release_channel - - status - - db_host - - db_port - UpdateBranchBody: - type: object - properties: - branch_name: - type: string - git_branch: - type: string - reset_on_push: - description: >- - This field is deprecated and will be ignored. Use v1-reset-a-branch - endpoint directly instead. - deprecated: true - type: boolean - persistent: - type: boolean - status: - type: string - enum: - - CREATING_PROJECT - - RUNNING_MIGRATIONS - - MIGRATIONS_PASSED - - MIGRATIONS_FAILED - - FUNCTIONS_DEPLOYED - - FUNCTIONS_FAILED - request_review: - type: boolean - notify_url: - type: string - format: uri - description: HTTP endpoint to receive branch status updates. - example: - branch_name: preview-login-page - git_branch: feature/login-page - persistent: true - request_review: true - notify_url: https://example.com/webhooks/branches - BranchResponse_Output: - type: object - properties: - id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - name: - type: string - project_ref: - type: string - parent_project_ref: - type: string - is_default: - type: boolean - git_branch: - type: string - pr_number: - type: integer - format: int32 - minimum: -9007199254740991 - maximum: 9007199254740991 - latest_check_run_id: - description: This field is deprecated and will not be populated. - deprecated: true - type: number - persistent: - type: boolean - status: - type: string - enum: - - CREATING_PROJECT - - RUNNING_MIGRATIONS - - MIGRATIONS_PASSED - - MIGRATIONS_FAILED - - FUNCTIONS_DEPLOYED - - FUNCTIONS_FAILED - description: >- - This field is deprecated. List action runs to get branch status - instead. - deprecated: true - created_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - review_requested_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - with_data: - type: boolean - notify_url: - type: string - format: uri - deletion_scheduled_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - preview_project_status: - type: string - enum: - - INACTIVE - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - UNKNOWN - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UPGRADING - - PAUSING - - RESTORE_FAILED - - RESTARTING - - PAUSE_FAILED - - RESIZING - required: - - id - - name - - project_ref - - parent_project_ref - - is_default - - persistent - - status - - created_at - - updated_at - - with_data - BranchDeleteResponse_Output: - type: object - properties: - message: - type: string - enum: - - ok - required: - - message - BranchActionBody: - type: object - properties: - migration_version: - type: string - example: - migration_version: '20250312000000' - BranchUpdateResponse_Output: - type: object - properties: - workflow_run_id: - type: string - message: - type: string - enum: - - ok - required: - - workflow_run_id - - message - BranchRestoreResponse_Output: - type: object - properties: - message: - type: string - enum: - - Branch restoration initiated - required: - - message - V1ProjectWithDatabaseResponse_Output: - type: object - properties: - id: - type: string - deprecated: true - description: 'Deprecated: Use `ref` instead.' - ref: - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - organization_id: - type: string - description: 'Deprecated: Use `organization_slug` instead.' - deprecated: true - organization_slug: - type: string - pattern: ^[\w-]+$ - description: Organization slug - example: tsrqponmlkjihgfedcba - name: - type: string - description: Name of your project - region: - type: string - description: Region of your project - created_at: - type: string - description: Creation timestamp - status: - type: string - enum: - - INACTIVE - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - UNKNOWN - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UPGRADING - - PAUSING - - RESTORE_FAILED - - RESTARTING - - PAUSE_FAILED - - RESIZING - database: - type: object - properties: - host: - type: string - description: Database host - version: - type: string - description: Database version - postgres_engine: - type: string - description: Database engine - release_channel: - type: string - description: Release channel - required: - - host - - version - - postgres_engine - - release_channel - required: - - id - - ref - - organization_id - - organization_slug - - name - - region - - created_at - - status - - database - V1CreateProjectBody: - type: object - properties: - db_pass: - type: string - description: Database password - name: - type: string - maxLength: 256 - description: Name of your project - organization_id: - deprecated: true - description: 'Deprecated: Use `organization_slug` instead.' - type: string - organization_slug: - type: string - pattern: ^[\w-]+$ - description: Organization slug - example: tsrqponmlkjihgfedcba - plan: - deprecated: true - description: >- - Subscription Plan is now set on organization level and is ignored in - this request - type: string - enum: - - free - - pro - region: - description: >- - Region you want your server to reside in. Use region_selection - instead. - deprecated: true - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - ap-east-1 - - ap-southeast-1 - - ap-northeast-1 - - ap-northeast-2 - - ap-southeast-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-north-1 - - eu-central-1 - - eu-central-2 - - ca-central-1 - - ap-south-1 - - sa-east-1 - type: string - region_selection: - description: >- - Region selection. Only one of region or region_selection can be - specified. - oneOf: - - type: object - properties: - type: - type: string - enum: - - specific - code: - type: string - description: >- - Specific region code. The codes supported are not a stable - API, and should be retrieved from the /available-regions - endpoint. - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - ap-east-1 - - ap-southeast-1 - - ap-northeast-1 - - ap-northeast-2 - - ap-southeast-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-north-1 - - eu-central-1 - - eu-central-2 - - ca-central-1 - - ap-south-1 - - sa-east-1 - required: - - type - - code - - type: object - properties: - type: - type: string - enum: - - smartGroup - code: - type: string - enum: - - americas - - emea - - apac - description: >- - The Smart Region Group's code. The codes supported are not a - stable API, and should be retrieved from the - /available-regions endpoint. - required: - - type - - code - kps_enabled: - deprecated: true - description: This field is deprecated and is ignored in this request - type: boolean - desired_instance_size: - description: >- - Desired instance size. Omit this field to always default to the - smallest possible size. - type: string - enum: - - nano - - micro - - small - - medium - - large - - xlarge - - 2xlarge - - 4xlarge - - 8xlarge - - 12xlarge - - 16xlarge - - 24xlarge - - 24xlarge_optimized_memory - - 24xlarge_optimized_cpu - - 24xlarge_high_memory - - 48xlarge - - 48xlarge_optimized_memory - - 48xlarge_optimized_cpu - - 48xlarge_high_memory - template_url: - description: Template URL used to create the project from the CLI. - type: string - format: uri - release_channel: - deprecated: true - type: 'null' - postgres_engine: - deprecated: true - type: 'null' - high_availability: - description: '[Experimental] Whether to enable high availability for the project.' - type: boolean - required: - - db_pass - - name - - organization_slug - example: - db_pass: correct-horse-battery-staple - name: acme-prod - organization_slug: tsrqponmlkjihgfedcba - region: us-east-1 - additionalProperties: false - V1ProjectResponse_Output: - type: object - properties: - id: - type: string - deprecated: true - description: 'Deprecated: Use `ref` instead.' - ref: - type: string - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - description: Project ref - example: abcdefghijklmnopqrst - organization_id: - type: string - description: 'Deprecated: Use `organization_slug` instead.' - deprecated: true - organization_slug: - type: string - pattern: ^[\w-]+$ - description: Organization slug - example: tsrqponmlkjihgfedcba - name: - type: string - description: Name of your project - region: - type: string - description: Region of your project - created_at: - type: string - description: Creation timestamp - status: - type: string - enum: - - INACTIVE - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - UNKNOWN - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UPGRADING - - PAUSING - - RESTORE_FAILED - - RESTARTING - - PAUSE_FAILED - - RESIZING - required: - - id - - ref - - organization_id - - organization_slug - - name - - region - - created_at - - status - RegionsInfoV1_Output: - type: object - properties: - recommendations: - type: object - properties: - smartGroup: - type: object - properties: - name: - type: string - code: - type: string - enum: - - americas - - emea - - apac - type: - type: string - enum: - - smartGroup - required: - - name - - code - - type - specific: - type: array - items: - type: object - properties: - name: - type: string - code: - type: string - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - ap-southeast-1 - - ap-northeast-1 - - ap-northeast-2 - - ap-east-1 - - ap-southeast-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-north-1 - - eu-central-1 - - eu-central-2 - - ca-central-1 - - ap-south-1 - - sa-east-1 - type: - type: string - enum: - - specific - provider: - type: string - enum: - - AWS - - AWS_K8S - - AWS_NIMBUS - status: - type: string - enum: - - capacity - - other - required: - - name - - code - - type - - provider - required: - - smartGroup - - specific - all: - type: object - properties: - smartGroup: - type: array - items: - type: object - properties: - name: - type: string - code: - type: string - enum: - - americas - - emea - - apac - type: - type: string - enum: - - smartGroup - required: - - name - - code - - type - specific: - type: array - items: - type: object - properties: - name: - type: string - code: - type: string - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - ap-southeast-1 - - ap-northeast-1 - - ap-northeast-2 - - ap-east-1 - - ap-southeast-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-north-1 - - eu-central-1 - - eu-central-2 - - ca-central-1 - - ap-south-1 - - sa-east-1 - type: - type: string - enum: - - specific - provider: - type: string - enum: - - AWS - - AWS_K8S - - AWS_NIMBUS - status: - type: string - enum: - - capacity - - other - required: - - name - - code - - type - - provider - required: - - smartGroup - - specific - required: - - recommendations - - all - OrganizationResponseV1_Output: - type: object - properties: - id: - type: string - description: 'Deprecated: Use `slug` instead.' - deprecated: true - slug: - type: string - pattern: ^[\w-]+$ - description: Organization slug - example: tsrqponmlkjihgfedcba - name: - type: string - required: - - id - - slug - - name - CreateOrganizationV1: - type: object - properties: - name: - type: string - maxLength: 256 - required: - - name - example: - name: Acme - additionalProperties: false - OAuthTokenBody: - type: object - properties: - grant_type: - type: string - enum: - - authorization_code - - refresh_token - - urn:ietf:params:oauth:grant-type:jwt-bearer - client_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - client_secret: - type: string - code: - type: string - code_verifier: - type: string - redirect_uri: - type: string - refresh_token: - type: string - assertion: - description: >- - IDJAG assertion JWT for - grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. Beta - - available on Team and Enterprise plans only. - type: string - resource: - description: Resource indicator for MCP (Model Context Protocol) clients - type: string - format: uri - scope: - type: string - example: - grant_type: authorization_code - client_id: 66666666-6666-4666-8666-666666666666 - client_secret: sb_secret_live_example_9f4d3a206b2e4a7e8c91 - code: oauth_code_9f4d3a206b2e4a7e8c91 - code_verifier: qW0Z6d9pQnW0mL1dK1q9wFq6Yz2nV5rA8jT3mP7sH4c - redirect_uri: https://app.acme.com/auth/callback - scope: projects:read projects:write - additionalProperties: false - OAuthTokenResponse_Output: - type: object - properties: - access_token: - type: string - refresh_token: - description: >- - The `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type issues - access tokens only, no refresh token is returned and the token - cannot be revoked via `/v1/oauth/revoke`. - type: string - expires_in: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - token_type: - type: string - enum: - - Bearer - required: - - access_token - - expires_in - - token_type - OAuthRevokeTokenBody: - type: object - properties: - client_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - client_secret: - type: string - refresh_token: - type: string - required: - - client_id - - client_secret - - refresh_token - example: - client_id: 66666666-6666-4666-8666-666666666666 - client_secret: sb_secret_live_example_9f4d3a206b2e4a7e8c91 - refresh_token: oauth_refresh_9f4d3a206b2e4a7e8c91 - additionalProperties: false - SnippetList_Output: - type: object - properties: - data: - type: array - items: - type: object - properties: - id: - type: string - inserted_at: - type: string - updated_at: - type: string - type: - type: string - enum: - - sql - visibility: - type: string - enum: - - user - - project - - org - - public - name: - type: string - description: - type: string - nullable: true - project: - type: object - properties: - id: - type: number - name: - type: string - required: - - id - - name - owner: - type: object - properties: - id: - type: number - username: - type: string - required: - - id - - username - updated_by: - type: object - properties: - id: - type: number - username: - type: string - required: - - id - - username - favorite: - type: boolean - required: - - id - - inserted_at - - updated_at - - type - - visibility - - name - - description - - project - - owner - - updated_by - - favorite - cursor: - type: string - required: - - data - SnippetResponse_Output: - type: object - properties: - id: - type: string - inserted_at: - type: string - updated_at: - type: string - type: - type: string - enum: - - sql - visibility: - type: string - enum: - - user - - project - - org - - public - name: - type: string - description: - type: string - nullable: true - project: - type: object - properties: - id: - type: number - name: - type: string - required: - - id - - name - owner: - type: object - properties: - id: - type: number - username: - type: string - required: - - id - - username - updated_by: - type: object - properties: - id: - type: number - username: - type: string - required: - - id - - username - favorite: - type: boolean - content: - type: object - properties: - favorite: - deprecated: true - description: 'Deprecated: Rely on root-level favorite property instead.' - type: boolean - schema_version: - type: string - sql: - type: string - required: - - sql - required: - - id - - inserted_at - - updated_at - - type - - visibility - - name - - description - - project - - owner - - updated_by - - favorite - - content - V1ProfileResponse_Output: - type: object - properties: - gotrue_id: - type: string - primary_email: - type: string - username: - type: string - required: - - gotrue_id - - primary_email - - username - ListActionRunResponse_Output: - type: array - items: - type: object - properties: - id: - type: string - branch_id: - type: string - run_steps: - type: array - items: - type: object - properties: - name: - type: string - enum: - - clone - - pull - - health - - configure - - migrate - - seed - - deploy - status: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - created_at: - type: string - updated_at: - type: string - required: - - name - - status - - created_at - - updated_at - git_config: - nullable: true - workdir: - type: string - nullable: true - check_run_id: - type: number - nullable: true - created_at: - type: string - updated_at: - type: string - required: - - id - - branch_id - - run_steps - - workdir - - check_run_id - - created_at - - updated_at - ActionRunResponse_Output: - type: object - properties: - id: - type: string - branch_id: - type: string - run_steps: - type: array - items: - type: object - properties: - name: - type: string - enum: - - clone - - pull - - health - - configure - - migrate - - seed - - deploy - status: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - created_at: - type: string - updated_at: - type: string - required: - - name - - status - - created_at - - updated_at - git_config: - nullable: true - workdir: - type: string - nullable: true - check_run_id: - type: number - nullable: true - created_at: - type: string - updated_at: - type: string - required: - - id - - branch_id - - run_steps - - workdir - - check_run_id - - created_at - - updated_at - UpdateRunStatusBody: - type: object - properties: - clone: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - pull: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - health: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - configure: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - migrate: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - seed: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - deploy: - type: string - enum: - - CREATED - - DEAD - - EXITED - - PAUSED - - REMOVING - - RESTARTING - - RUNNING - example: - clone: RUNNING - configure: RUNNING - migrate: RUNNING - deploy: CREATED - UpdateRunStatusResponse_Output: - type: object - properties: - message: - type: string - enum: - - ok - required: - - message - ApiKeyResponse_Output: - type: object - properties: - api_key: - type: string - nullable: true - id: - type: string - nullable: true - type: - type: string - enum: - - legacy - - publishable - - secret - - null - nullable: true - prefix: - type: string - nullable: true - name: - type: string - description: - type: string - nullable: true - hash: - type: string - nullable: true - secret_jwt_template: - type: object - propertyNames: - type: string - additionalProperties: {} - nullable: true - inserted_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - nullable: true - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - nullable: true - required: - - name - LegacyApiKeysResponse_Output: - type: object - properties: - enabled: - type: boolean - required: - - enabled - CreateApiKeyBody: - type: object - properties: - type: - type: string - enum: - - publishable - - secret - name: - type: string - minLength: 4 - maxLength: 64 - pattern: ^[a-z_][a-z0-9_]+$ - description: - type: string - nullable: true - secret_jwt_template: - type: object - propertyNames: - type: string - additionalProperties: {} - nullable: true - required: - - type - - name - example: - type: secret - name: ci_secret_key - description: CI deploy key - UpdateApiKeyBody: - type: object - properties: - name: - type: string - minLength: 4 - maxLength: 64 - pattern: ^[a-z_][a-z0-9_]+$ - description: - type: string - nullable: true - secret_jwt_template: - type: object - propertyNames: - type: string - additionalProperties: {} - nullable: true - example: - name: ci_secret_key_rotated - description: Rotated after March release - CreateBranchBody: - type: object - properties: - branch_name: - type: string - minLength: 1 - git_branch: - type: string - is_default: - type: boolean - persistent: - type: boolean - region: - type: string - desired_instance_size: - type: string - enum: - - pico - - nano - - micro - - small - - medium - - large - - xlarge - - 2xlarge - - 4xlarge - - 8xlarge - - 12xlarge - - 16xlarge - - 24xlarge - - 24xlarge_optimized_memory - - 24xlarge_optimized_cpu - - 24xlarge_high_memory - - 48xlarge - - 48xlarge_optimized_memory - - 48xlarge_optimized_cpu - - 48xlarge_high_memory - release_channel: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - description: Release channel. If not provided, GA will be used. - postgres_engine: - type: string - enum: - - '15' - - '17' - - 17-oriole - description: >- - Postgres engine version. If not provided, the latest version will be - used. - secrets: - type: object - additionalProperties: - type: string - with_data: - type: boolean - notify_url: - type: string - format: uri - description: HTTP endpoint to receive branch status updates. - required: - - branch_name - example: - branch_name: preview-login-page - git_branch: feature/login-page - persistent: true - with_data: false - notify_url: https://example.com/webhooks/branches - JsonValue_Output: - description: Any JSON-serializable value - anyOf: - - anyOf: - - type: string - - type: number - - type: boolean - nullable: true - - type: array - items: - $ref: '#/components/schemas/JsonValue_Output' - - type: object - additionalProperties: - $ref: '#/components/schemas/JsonValue_Output' - UpdateCustomHostnameResponse_Output: - type: object - properties: - status: - type: string - enum: - - 1_not_started - - 2_initiated - - 3_challenge_verified - - 4_origin_setup_completed - - 5_services_reconfigured - custom_hostname: - type: string - data: - type: object - properties: - success: - type: boolean - errors: - type: array - items: - $ref: '#/components/schemas/JsonValue_Output' - messages: - type: array - items: - $ref: '#/components/schemas/JsonValue_Output' - result: - type: object - properties: - id: - type: string - hostname: - type: string - ssl: - type: object - properties: - status: - type: string - validation_records: - type: array - items: - type: object - properties: - txt_name: - type: string - txt_value: - type: string - validation_errors: - type: array - items: - type: object - properties: - message: - type: string - required: - - message - ownership_verification: - type: object - properties: - type: - type: string - name: - type: string - value: - type: string - custom_origin_server: - type: string - verification_errors: - type: array - items: - type: string - status: - type: string - required: - - id - - hostname - required: - - success - - errors - - messages - - result - required: - - status - - data - UpdateCustomHostnameBody: - type: object - properties: - custom_hostname: - type: string - minLength: 1 - maxLength: 253 - required: - - custom_hostname - example: - custom_hostname: docs.example.com - JitAccessRequestRequest: - type: object - properties: - state: - type: string - enum: - - enabled - - disabled - required: - - state - example: - state: enabled - NetworkBanResponse_Output: - type: object - properties: - banned_ipv4_addresses: - type: array - items: - type: string - required: - - banned_ipv4_addresses - NetworkBanResponseEnriched_Output: - type: object - properties: - banned_ipv4_addresses: - type: array - items: - type: object - properties: - banned_address: - type: string - identifier: - type: string - type: - type: string - required: - - banned_address - - identifier - - type - required: - - banned_ipv4_addresses - RemoveNetworkBanRequest: - type: object - properties: - ipv4_addresses: - type: array - items: - type: string - description: List of IP addresses to unban. - requester_ip: - default: false - description: Include requester's public IP in the list of addresses to unban. - type: boolean - identifier: - type: string - required: - - ipv4_addresses - example: - ipv4_addresses: - - 203.0.113.10 - requester_ip: false - NetworkRestrictionsResponse_Output: - type: object - properties: - entitlement: - type: string - enum: - - disallowed - - allowed - config: - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: string - dbAllowedCidrsV6: - type: array - items: - type: string - example: - dbAllowedCidrs: - - 203.0.113.0/24 - dbAllowedCidrsV6: - - 2001:db8::/32 - description: >- - At any given point in time, this is the config that the user has - requested be applied to their project. The `status` field indicates - if it has been applied to the project, or is pending. When an - updated config is received, the applied config is moved to - `old_config`. - old_config: - description: >- - Populated when a new config has been received, but not registered as - successfully applied to a project. - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: string - dbAllowedCidrsV6: - type: array - items: - type: string - example: - dbAllowedCidrs: - - 203.0.113.0/24 - dbAllowedCidrsV6: - - 2001:db8::/32 - status: - type: string - enum: - - stored - - applied - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - applied_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - required: - - entitlement - - config - - status - NetworkRestrictionsRequest: - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: string - dbAllowedCidrsV6: - type: array - items: - type: string - example: - dbAllowedCidrs: - - 203.0.113.0/24 - dbAllowedCidrsV6: - - 2001:db8::/32 - NetworkRestrictionsPatchRequest: - type: object - properties: - add: - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: string - dbAllowedCidrsV6: - type: array - items: - type: string - remove: - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: string - dbAllowedCidrsV6: - type: array - items: - type: string - example: - add: - dbAllowedCidrs: - - 203.0.113.0/24 - remove: - dbAllowedCidrs: - - 198.51.100.0/24 - NetworkRestrictionsV2Response_Output: - type: object - properties: - entitlement: - type: string - enum: - - disallowed - - allowed - config: - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: object - properties: - address: - type: string - type: - type: string - enum: - - v4 - - v6 - required: - - address - - type - description: >- - At any given point in time, this is the config that the user has - requested be applied to their project. The `status` field indicates - if it has been applied to the project, or is pending. When an - updated config is received, the applied config is moved to - `old_config`. - old_config: - description: >- - Populated when a new config has been received, but not registered as - successfully applied to a project. - type: object - properties: - dbAllowedCidrs: - type: array - items: - type: object - properties: - address: - type: string - type: - type: string - enum: - - v4 - - v6 - required: - - address - - type - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - applied_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - status: - type: string - enum: - - stored - - applied - required: - - entitlement - - config - - status - PgsodiumConfigResponse_Output: - type: object - properties: - root_key: - type: string - description: 'The pgsodium root key: 32 bytes, hex-encoded (64 characters).' - required: - - root_key - example: - root_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - UpdatePgsodiumConfigBody: - type: object - properties: - root_key: - type: string - description: 'The pgsodium root key: 32 bytes, hex-encoded (64 characters).' - required: - - root_key - example: - root_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - PostgrestConfigWithJWTSecretResponse_Output: - type: object - properties: - db_schema: - type: string - max_rows: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - db_extra_search_path: - type: string - db_pool: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: >- - If `null`, the value is automatically configured based on compute - size. - nullable: true - db_pool_acquisition_timeout: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: If `null`, the value is automatically configured to 10. - nullable: true - jwt_secret: - type: string - required: - - db_schema - - max_rows - - db_extra_search_path - - db_pool - - db_pool_acquisition_timeout - V1UpdatePostgrestConfigBody: - type: object - properties: - db_extra_search_path: - type: string - db_schema: - type: string - max_rows: - type: integer - minimum: 0 - maximum: 1000000 - db_pool: - type: integer - minimum: 0 - maximum: 1000 - db_pool_acquisition_timeout: - type: integer - minimum: 0 - maximum: 60 - example: - db_schema: public,storage - db_pool: 20 - max_rows: 1000 - V1PostgrestConfigResponse_Output: - type: object - properties: - db_schema: - type: string - max_rows: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - db_extra_search_path: - type: string - db_pool: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: >- - If `null`, the value is automatically configured based on compute - size. - nullable: true - db_pool_acquisition_timeout: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: If `null`, the value is automatically configured to 10. - nullable: true - required: - - db_schema - - max_rows - - db_extra_search_path - - db_pool - - db_pool_acquisition_timeout - V1ProjectRefResponse_Output: - type: object - properties: - id: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - ref: - type: string - name: - type: string - required: - - id - - ref - - name - V1UpdateProjectBody: - type: object - properties: - name: - type: string - minLength: 1 - maxLength: 256 - required: - - name - example: - name: Acme Platform - SecretResponse_Output: - type: object - properties: - name: - type: string - value: - type: string - updated_at: - type: string - required: - - name - - value - CreateSecretBody: - maxItems: 100 - type: array - items: - type: object - properties: - name: - type: string - maxLength: 256 - pattern: ^(?!SUPABASE_).* - description: Secret name must not start with the SUPABASE_ prefix. - value: - type: string - maxLength: 24576 - required: - - name - - value - example: - - name: OPENAI_API_KEY - value: sk-example-secret - - name: STRIPE_WEBHOOK_SECRET - value: whsec_example - DeleteSecretsBody: - type: array - items: - type: string - example: - - OPENAI_API_KEY - SslEnforcementResponse_Output: - type: object - properties: - currentConfig: - type: object - properties: - database: - type: boolean - required: - - database - appliedSuccessfully: - type: boolean - required: - - currentConfig - - appliedSuccessfully - SslEnforcementRequest: - type: object - properties: - requestedConfig: - type: object - properties: - database: - type: boolean - required: - - database - required: - - requestedConfig - example: - requestedConfig: - database: true - TypescriptResponse_Output: - type: object - properties: - types: - type: string - required: - - types - VanitySubdomainConfigResponse_Output: - type: object - properties: - status: - type: string - enum: - - not-used - - custom-domain-used - - active - custom_domain: - type: string - minLength: 1 - required: - - status - PlanGateErrorBody: - type: object - properties: - message: - type: string - description: Human-readable explanation of the plan gate - error: - description: >- - Present on entitlement denials. Other errors with this status code - (validation, billing state) carry only message. - type: object - properties: - code: - type: string - description: Machine-readable marker for plan-gated denials - enum: - - entitlement_required - feature: - type: string - description: Entitlement feature key that failed the check - upgrade_url: - description: >- - Billing page URL for the organization, present when the org is - resolvable - type: string - required: - - code - - feature - required: - - message - VanitySubdomainBody: - type: object - properties: - vanity_subdomain: - type: string - maxLength: 63 - required: - - vanity_subdomain - example: - vanity_subdomain: acme-prod - SubdomainAvailabilityResponse_Output: - type: object - properties: - available: - type: boolean - required: - - available - ActivateVanitySubdomainResponse_Output: - type: object - properties: - custom_domain: - type: string - required: - - custom_domain - UpgradeDatabaseBody: - type: object - properties: - target_version: - type: string - release_channel: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - required: - - target_version - example: - target_version: '17' - release_channel: ga - ProjectUpgradeInitiateResponse_Output: - type: object - properties: - tracking_id: - type: string - required: - - tracking_id - ProjectUpgradeEligibilityResponse_Output: - type: object - properties: - eligible: - type: boolean - current_app_version: - type: string - current_app_version_release_channel: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - latest_app_version: - type: string - target_upgrade_versions: - type: array - items: - type: object - properties: - postgres_version: - type: string - enum: - - '13' - - '14' - - '15' - - '17' - - 17-oriole - release_channel: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - app_version: - type: string - required: - - postgres_version - - release_channel - - app_version - duration_estimate_hours: - type: number - legacy_auth_custom_roles: - type: array - items: - type: string - objects_to_be_dropped: - type: array - items: - type: string - deprecated: true - description: Use validation_errors instead. - unsupported_extensions: - type: array - items: - type: string - deprecated: true - description: Use validation_errors instead. - user_defined_objects_in_internal_schemas: - type: array - items: - type: string - deprecated: true - description: Use validation_errors instead. - validation_errors: - type: array - items: - anyOf: - - type: object - properties: - type: - type: string - enum: - - objects_depending_on_pg_cron - dependents: - type: array - items: - type: string - required: - - type - - dependents - - type: object - properties: - type: - type: string - enum: - - indexes_referencing_ll_to_earth - schema_name: - type: string - table_name: - type: string - index_name: - type: string - required: - - type - - schema_name - - table_name - - index_name - - type: object - properties: - type: - type: string - enum: - - function_using_obsolete_lang - schema_name: - type: string - function_name: - type: string - lang_name: - type: string - required: - - type - - schema_name - - function_name - - lang_name - - type: object - properties: - type: - type: string - enum: - - unsupported_extension - extension_name: - type: string - required: - - type - - extension_name - - type: object - properties: - type: - type: string - enum: - - unsupported_fdw_handler - fdw_name: - type: string - fdw_handler_name: - type: string - required: - - type - - fdw_name - - fdw_handler_name - - type: object - properties: - type: - type: string - enum: - - unlogged_table_with_persistent_sequence - schema_name: - type: string - table_name: - type: string - sequence_name: - type: string - required: - - type - - schema_name - - table_name - - sequence_name - - type: object - properties: - type: - type: string - enum: - - user_defined_objects_in_internal_schemas - obj_type: - anyOf: - - type: string - enum: - - table - - type: string - enum: - - function - schema_name: - type: string - obj_name: - type: string - required: - - type - - obj_type - - schema_name - - obj_name - - type: object - properties: - type: - type: string - enum: - - active_replication_slot - slot_name: - type: string - required: - - type - - slot_name - - type: object - properties: - type: - type: string - enum: - - x86_architecture - required: - - type - - type: object - properties: - type: - type: string - enum: - - project_hibernating - required: - - type - warnings: - type: array - items: - oneOf: - - type: object - properties: - type: - type: string - enum: - - pg_graphql_introspection_change - required: - - type - - type: object - properties: - type: - type: string - enum: - - ltree_reindex_required - required: - - type - - type: object - properties: - type: - type: string - enum: - - operator_estimator_gate - required: - - type - - type: object - properties: - type: - type: string - enum: - - btree_gist_nan_reindex - required: - - type - required: - - eligible - - current_app_version - - current_app_version_release_channel - - latest_app_version - - target_upgrade_versions - - duration_estimate_hours - - legacy_auth_custom_roles - - objects_to_be_dropped - - unsupported_extensions - - user_defined_objects_in_internal_schemas - - validation_errors - - warnings - DatabaseUpgradeStatusResponse_Output: - type: object - properties: - databaseUpgradeStatus: - type: object - properties: - initiated_at: - type: string - latest_status_at: - type: string - target_version: - type: string - error: - type: string - enum: - - 1_upgraded_instance_launch_failed - - 2_volume_detachchment_from_upgraded_instance_failed - - 3_volume_attachment_to_original_instance_failed - - 4_data_upgrade_initiation_failed - - 5_data_upgrade_completion_failed - - 6_volume_detachchment_from_original_instance_failed - - 7_volume_attachment_to_upgraded_instance_failed - - 8_upgrade_completion_failed - - 9_post_physical_backup_failed - progress: - type: string - enum: - - 0_requested - - 1_started - - 2_launched_upgraded_instance - - 3_detached_volume_from_upgraded_instance - - 4_attached_volume_to_original_instance - - 5_initiated_data_upgrade - - 6_completed_data_upgrade - - 7_detached_volume_from_original_instance - - 8_attached_volume_to_upgraded_instance - - 9_completed_upgrade - - 10_completed_post_physical_backup - status: - type: number - required: - - initiated_at - - latest_status_at - - target_version - - status - nullable: true - required: - - databaseUpgradeStatus - ReadOnlyStatusResponse_Output: - type: object - properties: - enabled: - type: boolean - override_enabled: - type: boolean - override_active_until: - type: string - required: - - enabled - - override_enabled - - override_active_until - SetUpReadReplicaBody: - type: object - properties: - read_replica_region: - type: string - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - ap-east-1 - - ap-southeast-1 - - ap-northeast-1 - - ap-northeast-2 - - ap-southeast-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-north-1 - - eu-central-1 - - eu-central-2 - - ca-central-1 - - ap-south-1 - - sa-east-1 - description: Region you want your read replica to reside in - required: - - read_replica_region - example: - read_replica_region: us-west-1 - RemoveReadReplicaBody: - type: object - properties: - database_identifier: - type: string - required: - - database_identifier - example: - database_identifier: abcdefghijklmnopqrst-rr-us-west-1-abcde - V1ServiceHealthResponse_Output: - type: object - properties: - name: - type: string - enum: - - auth - - db - - db_postgres_user - - pooler - - realtime - - rest - - storage - - pg_bouncer - healthy: - type: boolean - deprecated: true - description: Deprecated. Use `status` instead. - status: - type: string - enum: - - COMING_UP - - ACTIVE_HEALTHY - - UNHEALTHY - info: - anyOf: - - type: object - properties: - name: - type: string - enum: - - GoTrue - version: - type: string - description: - type: string - required: - - name - - version - - description - - type: object - properties: - healthy: - type: boolean - deprecated: true - description: Deprecated. Use `status` instead. - db_connected: - type: boolean - replication_connected: - type: boolean - connected_cluster: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - required: - - healthy - - db_connected - - replication_connected - - connected_cluster - - type: object - properties: - db_schema: - type: string - required: - - db_schema - error: - type: string - required: - - name - - healthy - - status - SigningKeyResponse_Output: - type: object - properties: - id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - algorithm: - type: string - enum: - - EdDSA - - ES256 - - RS256 - - HS256 - status: - type: string - enum: - - in_use - - previously_used - - revoked - - standby - public_jwk: - nullable: true - created_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - required: - - id - - algorithm - - status - - public_jwk - - created_at - - updated_at - CreateSigningKeyBody: - type: object - properties: - algorithm: - type: string - enum: - - EdDSA - - ES256 - - RS256 - - HS256 - status: - type: string - enum: - - in_use - - standby - private_jwk: - oneOf: - - type: object - properties: - kid: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - use: - type: string - enum: - - sig - key_ops: - minItems: 2 - maxItems: 2 - type: array - items: - type: string - enum: - - sign - - verify - ext: - type: boolean - enum: - - true - kty: - type: string - enum: - - RSA - alg: - type: string - enum: - - RS256 - 'n': - type: string - e: - type: string - enum: - - AQAB - d: - type: string - p: - type: string - q: - type: string - dp: - type: string - dq: - type: string - qi: - type: string - required: - - kty - - 'n' - - e - - d - - p - - q - - dp - - dq - - qi - additionalProperties: false - - type: object - properties: - kid: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - use: - type: string - enum: - - sig - key_ops: - minItems: 2 - maxItems: 2 - type: array - items: - type: string - enum: - - sign - - verify - ext: - type: boolean - enum: - - true - kty: - type: string - enum: - - EC - alg: - type: string - enum: - - ES256 - crv: - type: string - enum: - - P-256 - x: - type: string - 'y': - type: string - d: - type: string - required: - - kty - - crv - - x - - 'y' - - d - additionalProperties: false - - type: object - properties: - kid: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - use: - type: string - enum: - - sig - key_ops: - minItems: 2 - maxItems: 2 - type: array - items: - type: string - enum: - - sign - - verify - ext: - type: boolean - enum: - - true - kty: - type: string - enum: - - OKP - alg: - type: string - enum: - - EdDSA - crv: - type: string - enum: - - Ed25519 - x: - type: string - d: - type: string - required: - - kty - - crv - - x - - d - additionalProperties: false - - type: object - properties: - kid: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - use: - type: string - enum: - - sig - key_ops: - minItems: 2 - maxItems: 2 - type: array - items: - type: string - enum: - - sign - - verify - ext: - type: boolean - enum: - - true - kty: - type: string - enum: - - oct - alg: - type: string - enum: - - HS256 - k: - type: string - minLength: 16 - required: - - kty - - k - additionalProperties: false - required: - - algorithm - example: - algorithm: RS256 - status: standby - additionalProperties: false - SigningKeysResponse_Output: - type: object - properties: - keys: - type: array - items: - type: object - properties: - id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - algorithm: - type: string - enum: - - EdDSA - - ES256 - - RS256 - - HS256 - status: - type: string - enum: - - in_use - - previously_used - - revoked - - standby - public_jwk: - nullable: true - created_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - required: - - id - - algorithm - - status - - public_jwk - - created_at - - updated_at - required: - - keys - UpdateSigningKeyBody: - type: object - properties: - status: - type: string - enum: - - in_use - - previously_used - - revoked - - standby - required: - - status - example: - status: standby - additionalProperties: false - AuthConfigResponse_Output: - type: object - properties: - api_max_request_duration: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - db_max_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - db_max_pool_size_unit: - type: string - enum: - - connections - - percent - - null - nullable: true - disable_signup: - type: boolean - nullable: true - external_anonymous_users_enabled: - type: boolean - nullable: true - external_apple_additional_client_ids: - type: string - nullable: true - external_apple_client_id: - type: string - nullable: true - external_apple_email_optional: - type: boolean - nullable: true - external_apple_enabled: - type: boolean - nullable: true - external_apple_secret: - type: string - nullable: true - external_azure_client_id: - type: string - nullable: true - external_azure_email_optional: - type: boolean - nullable: true - external_azure_enabled: - type: boolean - nullable: true - external_azure_secret: - type: string - nullable: true - external_azure_url: - type: string - nullable: true - external_bitbucket_client_id: - type: string - nullable: true - external_bitbucket_email_optional: - type: boolean - nullable: true - external_bitbucket_enabled: - type: boolean - nullable: true - external_bitbucket_secret: - type: string - nullable: true - external_discord_client_id: - type: string - nullable: true - external_discord_email_optional: - type: boolean - nullable: true - external_discord_enabled: - type: boolean - nullable: true - external_discord_secret: - type: string - nullable: true - external_email_enabled: - type: boolean - nullable: true - external_facebook_client_id: - type: string - nullable: true - external_facebook_email_optional: - type: boolean - nullable: true - external_facebook_enabled: - type: boolean - nullable: true - external_facebook_secret: - type: string - nullable: true - external_figma_client_id: - type: string - nullable: true - external_figma_email_optional: - type: boolean - nullable: true - external_figma_enabled: - type: boolean - nullable: true - external_figma_secret: - type: string - nullable: true - external_github_client_id: - type: string - nullable: true - external_github_email_optional: - type: boolean - nullable: true - external_github_enabled: - type: boolean - nullable: true - external_github_secret: - type: string - nullable: true - external_gitlab_client_id: - type: string - nullable: true - external_gitlab_email_optional: - type: boolean - nullable: true - external_gitlab_enabled: - type: boolean - nullable: true - external_gitlab_secret: - type: string - nullable: true - external_gitlab_url: - type: string - nullable: true - external_google_additional_client_ids: - type: string - nullable: true - external_google_client_id: - type: string - nullable: true - external_google_email_optional: - type: boolean - nullable: true - external_google_enabled: - type: boolean - nullable: true - external_google_secret: - type: string - nullable: true - external_google_skip_nonce_check: - type: boolean - nullable: true - external_kakao_client_id: - type: string - nullable: true - external_kakao_email_optional: - type: boolean - nullable: true - external_kakao_enabled: - type: boolean - nullable: true - external_kakao_secret: - type: string - nullable: true - external_keycloak_client_id: - type: string - nullable: true - external_keycloak_email_optional: - type: boolean - nullable: true - external_keycloak_enabled: - type: boolean - nullable: true - external_keycloak_secret: - type: string - nullable: true - external_keycloak_url: - type: string - nullable: true - external_linkedin_oidc_client_id: - type: string - nullable: true - external_linkedin_oidc_email_optional: - type: boolean - nullable: true - external_linkedin_oidc_enabled: - type: boolean - nullable: true - external_linkedin_oidc_secret: - type: string - nullable: true - external_slack_oidc_client_id: - type: string - nullable: true - external_slack_oidc_email_optional: - type: boolean - nullable: true - external_slack_oidc_enabled: - type: boolean - nullable: true - external_slack_oidc_secret: - type: string - nullable: true - external_notion_client_id: - type: string - nullable: true - external_notion_email_optional: - type: boolean - nullable: true - external_notion_enabled: - type: boolean - nullable: true - external_notion_secret: - type: string - nullable: true - external_phone_enabled: - type: boolean - nullable: true - external_slack_client_id: - type: string - nullable: true - external_slack_email_optional: - type: boolean - nullable: true - external_slack_enabled: - type: boolean - nullable: true - external_slack_secret: - type: string - nullable: true - external_spotify_client_id: - type: string - nullable: true - external_spotify_email_optional: - type: boolean - nullable: true - external_spotify_enabled: - type: boolean - nullable: true - external_spotify_secret: - type: string - nullable: true - external_twitch_client_id: - type: string - nullable: true - external_twitch_email_optional: - type: boolean - nullable: true - external_twitch_enabled: - type: boolean - nullable: true - external_twitch_secret: - type: string - nullable: true - external_twitter_client_id: - type: string - nullable: true - external_twitter_email_optional: - type: boolean - nullable: true - external_twitter_enabled: - type: boolean - nullable: true - external_twitter_secret: - type: string - nullable: true - external_x_client_id: - type: string - nullable: true - external_x_email_optional: - type: boolean - nullable: true - external_x_enabled: - type: boolean - nullable: true - external_x_secret: - type: string - nullable: true - external_workos_client_id: - type: string - nullable: true - external_workos_enabled: - type: boolean - nullable: true - external_workos_secret: - type: string - nullable: true - external_workos_url: - type: string - nullable: true - external_web3_solana_enabled: - type: boolean - nullable: true - external_web3_ethereum_enabled: - type: boolean - nullable: true - external_zoom_client_id: - type: string - nullable: true - external_zoom_email_optional: - type: boolean - nullable: true - external_zoom_enabled: - type: boolean - nullable: true - external_zoom_secret: - type: string - nullable: true - hook_custom_access_token_enabled: - type: boolean - nullable: true - hook_custom_access_token_uri: - type: string - nullable: true - hook_custom_access_token_secrets: - type: string - nullable: true - hook_mfa_verification_attempt_enabled: - type: boolean - nullable: true - hook_mfa_verification_attempt_uri: - type: string - nullable: true - hook_mfa_verification_attempt_secrets: - type: string - nullable: true - hook_password_verification_attempt_enabled: - type: boolean - nullable: true - hook_password_verification_attempt_uri: - type: string - nullable: true - hook_password_verification_attempt_secrets: - type: string - nullable: true - hook_send_sms_enabled: - type: boolean - nullable: true - hook_send_sms_uri: - type: string - nullable: true - hook_send_sms_secrets: - type: string - nullable: true - hook_send_email_enabled: - type: boolean - nullable: true - hook_send_email_uri: - type: string - nullable: true - hook_send_email_secrets: - type: string - nullable: true - hook_before_user_created_enabled: - type: boolean - nullable: true - hook_before_user_created_uri: - type: string - nullable: true - hook_before_user_created_secrets: - type: string - nullable: true - hook_after_user_created_enabled: - type: boolean - nullable: true - hook_after_user_created_uri: - type: string - nullable: true - hook_after_user_created_secrets: - type: string - nullable: true - jwt_exp: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - mailer_allow_unverified_email_sign_ins: - type: boolean - nullable: true - mailer_autoconfirm: - type: boolean - nullable: true - mailer_otp_exp: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - mailer_otp_length: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - mailer_secure_email_change_enabled: - type: boolean - nullable: true - mailer_subjects_confirmation: - type: string - nullable: true - mailer_subjects_email_change: - type: string - nullable: true - mailer_subjects_invite: - type: string - nullable: true - mailer_subjects_magic_link: - type: string - nullable: true - mailer_subjects_reauthentication: - type: string - nullable: true - mailer_subjects_recovery: - type: string - nullable: true - mailer_subjects_password_changed_notification: - type: string - nullable: true - mailer_subjects_email_changed_notification: - type: string - nullable: true - mailer_subjects_phone_changed_notification: - type: string - nullable: true - mailer_subjects_mfa_factor_enrolled_notification: - type: string - nullable: true - mailer_subjects_mfa_factor_unenrolled_notification: - type: string - nullable: true - mailer_subjects_identity_linked_notification: - type: string - nullable: true - mailer_subjects_identity_unlinked_notification: - type: string - nullable: true - mailer_templates_confirmation_content: - type: string - nullable: true - mailer_templates_email_change_content: - type: string - nullable: true - mailer_templates_invite_content: - type: string - nullable: true - mailer_templates_magic_link_content: - type: string - nullable: true - mailer_templates_reauthentication_content: - type: string - nullable: true - mailer_templates_recovery_content: - type: string - nullable: true - mailer_templates_password_changed_notification_content: - type: string - nullable: true - mailer_templates_email_changed_notification_content: - type: string - nullable: true - mailer_templates_phone_changed_notification_content: - type: string - nullable: true - mailer_templates_mfa_factor_enrolled_notification_content: - type: string - nullable: true - mailer_templates_mfa_factor_unenrolled_notification_content: - type: string - nullable: true - mailer_templates_identity_linked_notification_content: - type: string - nullable: true - mailer_templates_identity_unlinked_notification_content: - type: string - nullable: true - mailer_notifications_password_changed_enabled: - type: boolean - nullable: true - mailer_notifications_email_changed_enabled: - type: boolean - nullable: true - mailer_notifications_phone_changed_enabled: - type: boolean - nullable: true - mailer_notifications_mfa_factor_enrolled_enabled: - type: boolean - nullable: true - mailer_notifications_mfa_factor_unenrolled_enabled: - type: boolean - nullable: true - mailer_notifications_identity_linked_enabled: - type: boolean - nullable: true - mailer_notifications_identity_unlinked_enabled: - type: boolean - nullable: true - mfa_max_enrolled_factors: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - mfa_totp_enroll_enabled: - type: boolean - nullable: true - mfa_totp_verify_enabled: - type: boolean - nullable: true - mfa_phone_enroll_enabled: - type: boolean - nullable: true - mfa_phone_verify_enabled: - type: boolean - nullable: true - mfa_web_authn_enroll_enabled: - type: boolean - nullable: true - mfa_web_authn_verify_enabled: - type: boolean - nullable: true - passkey_enabled: - type: boolean - webauthn_rp_display_name: - type: string - nullable: true - webauthn_rp_id: - type: string - nullable: true - webauthn_rp_origins: - type: string - nullable: true - mfa_phone_otp_length: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - mfa_phone_template: - type: string - nullable: true - mfa_phone_max_frequency: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - nimbus_oauth_client_id: - type: string - nullable: true - nimbus_oauth_email_optional: - type: boolean - nullable: true - nimbus_oauth_client_secret: - type: string - nullable: true - password_hibp_enabled: - type: boolean - nullable: true - password_min_length: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - password_required_characters: - type: string - enum: - - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ - - '' - - null - nullable: true - rate_limit_anonymous_users: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_email_sent: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_sms_sent: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_token_refresh: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_verify: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_otp: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - rate_limit_web3: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - refresh_token_rotation_enabled: - type: boolean - nullable: true - saml_enabled: - type: boolean - nullable: true - saml_external_url: - type: string - nullable: true - saml_allow_encrypted_assertions: - type: boolean - nullable: true - security_sb_forwarded_for_enabled: - type: boolean - nullable: true - security_captcha_enabled: - type: boolean - nullable: true - security_captcha_provider: - type: string - enum: - - turnstile - - hcaptcha - - null - nullable: true - security_captcha_secret: - type: string - nullable: true - security_manual_linking_enabled: - type: boolean - nullable: true - security_refresh_token_reuse_interval: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - security_update_password_require_current_password: - type: boolean - nullable: true - security_update_password_require_reauthentication: - type: boolean - nullable: true - sessions_inactivity_timeout: - type: number - nullable: true - sessions_single_per_user: - type: boolean - nullable: true - sessions_tags: - type: string - nullable: true - sessions_timebox: - type: number - nullable: true - site_url: - type: string - nullable: true - sms_autoconfirm: - type: boolean - nullable: true - sms_max_frequency: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - sms_messagebird_access_key: - type: string - nullable: true - sms_messagebird_originator: - type: string - nullable: true - sms_otp_exp: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - sms_otp_length: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - sms_provider: - type: string - enum: - - messagebird - - textlocal - - twilio - - twilio_verify - - vonage - - null - nullable: true - sms_template: - type: string - nullable: true - sms_test_otp: - type: string - nullable: true - sms_test_otp_valid_until: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - nullable: true - sms_textlocal_api_key: - type: string - nullable: true - sms_textlocal_sender: - type: string - nullable: true - sms_twilio_account_sid: - type: string - nullable: true - sms_twilio_auth_token: - type: string - nullable: true - sms_twilio_content_sid: - type: string - nullable: true - sms_twilio_message_service_sid: - type: string - nullable: true - sms_twilio_verify_account_sid: - type: string - nullable: true - sms_twilio_verify_auth_token: - type: string - nullable: true - sms_twilio_verify_message_service_sid: - type: string - nullable: true - sms_vonage_api_key: - type: string - nullable: true - sms_vonage_api_secret: - type: string - nullable: true - sms_vonage_from: - type: string - nullable: true - smtp_admin_email: - type: string - format: email - pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - nullable: true - smtp_host: - type: string - nullable: true - smtp_max_frequency: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - smtp_pass: - type: string - nullable: true - smtp_port: - type: string - nullable: true - smtp_sender_name: - type: string - nullable: true - smtp_user: - type: string - nullable: true - uri_allow_list: - type: string - nullable: true - oauth_server_enabled: - type: boolean - oauth_server_allow_dynamic_registration: - type: boolean - oauth_server_authorization_path: - type: string - nullable: true - custom_oauth_enabled: - type: boolean - custom_oauth_max_providers: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - required: - - api_max_request_duration - - db_max_pool_size - - db_max_pool_size_unit - - disable_signup - - external_anonymous_users_enabled - - external_apple_additional_client_ids - - external_apple_client_id - - external_apple_email_optional - - external_apple_enabled - - external_apple_secret - - external_azure_client_id - - external_azure_email_optional - - external_azure_enabled - - external_azure_secret - - external_azure_url - - external_bitbucket_client_id - - external_bitbucket_email_optional - - external_bitbucket_enabled - - external_bitbucket_secret - - external_discord_client_id - - external_discord_email_optional - - external_discord_enabled - - external_discord_secret - - external_email_enabled - - external_facebook_client_id - - external_facebook_email_optional - - external_facebook_enabled - - external_facebook_secret - - external_figma_client_id - - external_figma_email_optional - - external_figma_enabled - - external_figma_secret - - external_github_client_id - - external_github_email_optional - - external_github_enabled - - external_github_secret - - external_gitlab_client_id - - external_gitlab_email_optional - - external_gitlab_enabled - - external_gitlab_secret - - external_gitlab_url - - external_google_additional_client_ids - - external_google_client_id - - external_google_email_optional - - external_google_enabled - - external_google_secret - - external_google_skip_nonce_check - - external_kakao_client_id - - external_kakao_email_optional - - external_kakao_enabled - - external_kakao_secret - - external_keycloak_client_id - - external_keycloak_email_optional - - external_keycloak_enabled - - external_keycloak_secret - - external_keycloak_url - - external_linkedin_oidc_client_id - - external_linkedin_oidc_email_optional - - external_linkedin_oidc_enabled - - external_linkedin_oidc_secret - - external_slack_oidc_client_id - - external_slack_oidc_email_optional - - external_slack_oidc_enabled - - external_slack_oidc_secret - - external_notion_client_id - - external_notion_email_optional - - external_notion_enabled - - external_notion_secret - - external_phone_enabled - - external_slack_client_id - - external_slack_email_optional - - external_slack_enabled - - external_slack_secret - - external_spotify_client_id - - external_spotify_email_optional - - external_spotify_enabled - - external_spotify_secret - - external_twitch_client_id - - external_twitch_email_optional - - external_twitch_enabled - - external_twitch_secret - - external_twitter_client_id - - external_twitter_email_optional - - external_twitter_enabled - - external_twitter_secret - - external_x_client_id - - external_x_email_optional - - external_x_enabled - - external_x_secret - - external_workos_client_id - - external_workos_enabled - - external_workos_secret - - external_workos_url - - external_web3_solana_enabled - - external_web3_ethereum_enabled - - external_zoom_client_id - - external_zoom_email_optional - - external_zoom_enabled - - external_zoom_secret - - hook_custom_access_token_enabled - - hook_custom_access_token_uri - - hook_custom_access_token_secrets - - hook_mfa_verification_attempt_enabled - - hook_mfa_verification_attempt_uri - - hook_mfa_verification_attempt_secrets - - hook_password_verification_attempt_enabled - - hook_password_verification_attempt_uri - - hook_password_verification_attempt_secrets - - hook_send_sms_enabled - - hook_send_sms_uri - - hook_send_sms_secrets - - hook_send_email_enabled - - hook_send_email_uri - - hook_send_email_secrets - - hook_before_user_created_enabled - - hook_before_user_created_uri - - hook_before_user_created_secrets - - hook_after_user_created_enabled - - hook_after_user_created_uri - - hook_after_user_created_secrets - - jwt_exp - - mailer_allow_unverified_email_sign_ins - - mailer_autoconfirm - - mailer_otp_exp - - mailer_otp_length - - mailer_secure_email_change_enabled - - mailer_subjects_confirmation - - mailer_subjects_email_change - - mailer_subjects_invite - - mailer_subjects_magic_link - - mailer_subjects_reauthentication - - mailer_subjects_recovery - - mailer_subjects_password_changed_notification - - mailer_subjects_email_changed_notification - - mailer_subjects_phone_changed_notification - - mailer_subjects_mfa_factor_enrolled_notification - - mailer_subjects_mfa_factor_unenrolled_notification - - mailer_subjects_identity_linked_notification - - mailer_subjects_identity_unlinked_notification - - mailer_templates_confirmation_content - - mailer_templates_email_change_content - - mailer_templates_invite_content - - mailer_templates_magic_link_content - - mailer_templates_reauthentication_content - - mailer_templates_recovery_content - - mailer_templates_password_changed_notification_content - - mailer_templates_email_changed_notification_content - - mailer_templates_phone_changed_notification_content - - mailer_templates_mfa_factor_enrolled_notification_content - - mailer_templates_mfa_factor_unenrolled_notification_content - - mailer_templates_identity_linked_notification_content - - mailer_templates_identity_unlinked_notification_content - - mailer_notifications_password_changed_enabled - - mailer_notifications_email_changed_enabled - - mailer_notifications_phone_changed_enabled - - mailer_notifications_mfa_factor_enrolled_enabled - - mailer_notifications_mfa_factor_unenrolled_enabled - - mailer_notifications_identity_linked_enabled - - mailer_notifications_identity_unlinked_enabled - - mfa_max_enrolled_factors - - mfa_totp_enroll_enabled - - mfa_totp_verify_enabled - - mfa_phone_enroll_enabled - - mfa_phone_verify_enabled - - mfa_web_authn_enroll_enabled - - mfa_web_authn_verify_enabled - - passkey_enabled - - webauthn_rp_display_name - - webauthn_rp_id - - webauthn_rp_origins - - mfa_phone_otp_length - - mfa_phone_template - - mfa_phone_max_frequency - - nimbus_oauth_client_id - - nimbus_oauth_email_optional - - nimbus_oauth_client_secret - - password_hibp_enabled - - password_min_length - - password_required_characters - - rate_limit_anonymous_users - - rate_limit_email_sent - - rate_limit_sms_sent - - rate_limit_token_refresh - - rate_limit_verify - - rate_limit_otp - - rate_limit_web3 - - refresh_token_rotation_enabled - - saml_enabled - - saml_external_url - - saml_allow_encrypted_assertions - - security_sb_forwarded_for_enabled - - security_captcha_enabled - - security_captcha_provider - - security_captcha_secret - - security_manual_linking_enabled - - security_refresh_token_reuse_interval - - security_update_password_require_current_password - - security_update_password_require_reauthentication - - sessions_inactivity_timeout - - sessions_single_per_user - - sessions_tags - - sessions_timebox - - site_url - - sms_autoconfirm - - sms_max_frequency - - sms_messagebird_access_key - - sms_messagebird_originator - - sms_otp_exp - - sms_otp_length - - sms_provider - - sms_template - - sms_test_otp - - sms_test_otp_valid_until - - sms_textlocal_api_key - - sms_textlocal_sender - - sms_twilio_account_sid - - sms_twilio_auth_token - - sms_twilio_content_sid - - sms_twilio_message_service_sid - - sms_twilio_verify_account_sid - - sms_twilio_verify_auth_token - - sms_twilio_verify_message_service_sid - - sms_vonage_api_key - - sms_vonage_api_secret - - sms_vonage_from - - smtp_admin_email - - smtp_host - - smtp_max_frequency - - smtp_pass - - smtp_port - - smtp_sender_name - - smtp_user - - uri_allow_list - - oauth_server_enabled - - oauth_server_allow_dynamic_registration - - oauth_server_authorization_path - - custom_oauth_enabled - - custom_oauth_max_providers - UpdateAuthConfigBody: - type: object - properties: - site_url: - type: string - pattern: ^[^,]+$ - nullable: true - disable_signup: - type: boolean - nullable: true - jwt_exp: - type: integer - minimum: 0 - maximum: 604800 - nullable: true - smtp_admin_email: - type: string - format: email - pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - nullable: true - smtp_host: - type: string - nullable: true - smtp_port: - type: string - nullable: true - smtp_user: - type: string - nullable: true - smtp_pass: - type: string - nullable: true - smtp_max_frequency: - type: integer - minimum: 0 - maximum: 32767 - nullable: true - smtp_sender_name: - type: string - nullable: true - mailer_allow_unverified_email_sign_ins: - type: boolean - nullable: true - mailer_autoconfirm: - type: boolean - nullable: true - mailer_subjects_invite: - type: string - nullable: true - mailer_subjects_confirmation: - type: string - nullable: true - mailer_subjects_recovery: - type: string - nullable: true - mailer_subjects_email_change: - type: string - nullable: true - mailer_subjects_magic_link: - type: string - nullable: true - mailer_subjects_reauthentication: - type: string - nullable: true - mailer_subjects_password_changed_notification: - type: string - nullable: true - mailer_subjects_email_changed_notification: - type: string - nullable: true - mailer_subjects_phone_changed_notification: - type: string - nullable: true - mailer_subjects_mfa_factor_enrolled_notification: - type: string - nullable: true - mailer_subjects_mfa_factor_unenrolled_notification: - type: string - nullable: true - mailer_subjects_identity_linked_notification: - type: string - nullable: true - mailer_subjects_identity_unlinked_notification: - type: string - nullable: true - mailer_templates_invite_content: - type: string - nullable: true - mailer_templates_confirmation_content: - type: string - nullable: true - mailer_templates_recovery_content: - type: string - nullable: true - mailer_templates_email_change_content: - type: string - nullable: true - mailer_templates_magic_link_content: - type: string - nullable: true - mailer_templates_reauthentication_content: - type: string - nullable: true - mailer_templates_password_changed_notification_content: - type: string - nullable: true - mailer_templates_email_changed_notification_content: - type: string - nullable: true - mailer_templates_phone_changed_notification_content: - type: string - nullable: true - mailer_templates_mfa_factor_enrolled_notification_content: - type: string - nullable: true - mailer_templates_mfa_factor_unenrolled_notification_content: - type: string - nullable: true - mailer_templates_identity_linked_notification_content: - type: string - nullable: true - mailer_templates_identity_unlinked_notification_content: - type: string - nullable: true - mailer_notifications_password_changed_enabled: - type: boolean - nullable: true - mailer_notifications_email_changed_enabled: - type: boolean - nullable: true - mailer_notifications_phone_changed_enabled: - type: boolean - nullable: true - mailer_notifications_mfa_factor_enrolled_enabled: - type: boolean - nullable: true - mailer_notifications_mfa_factor_unenrolled_enabled: - type: boolean - nullable: true - mailer_notifications_identity_linked_enabled: - type: boolean - nullable: true - mailer_notifications_identity_unlinked_enabled: - type: boolean - nullable: true - mfa_max_enrolled_factors: - type: integer - minimum: 0 - maximum: 2147483647 - nullable: true - uri_allow_list: - type: string - nullable: true - external_anonymous_users_enabled: - type: boolean - nullable: true - external_email_enabled: - type: boolean - nullable: true - external_phone_enabled: - type: boolean - nullable: true - saml_enabled: - type: boolean - nullable: true - saml_external_url: - type: string - pattern: ^[^,]+$ - nullable: true - security_sb_forwarded_for_enabled: - type: boolean - nullable: true - security_captcha_enabled: - type: boolean - nullable: true - security_captcha_provider: - type: string - enum: - - turnstile - - hcaptcha - - null - nullable: true - security_captcha_secret: - type: string - nullable: true - sessions_timebox: - type: number - minimum: 0 - description: Session timebox in hours. Maximum 8760 hours (1 year). - nullable: true - sessions_inactivity_timeout: - type: number - minimum: 0 - description: Session inactivity timeout in hours. Maximum 8760 hours (1 year). - nullable: true - sessions_single_per_user: - type: boolean - nullable: true - sessions_tags: - type: string - pattern: ^\s*([a-zA-Z0-9_-]+(\s*,+\s*)?)*\s*$ - nullable: true - rate_limit_anonymous_users: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_email_sent: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_sms_sent: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_verify: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_token_refresh: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_otp: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - rate_limit_web3: - type: integer - minimum: 1 - maximum: 2147483647 - nullable: true - mailer_secure_email_change_enabled: - type: boolean - nullable: true - refresh_token_rotation_enabled: - type: boolean - nullable: true - password_hibp_enabled: - type: boolean - nullable: true - password_min_length: - type: integer - minimum: 6 - maximum: 32767 - nullable: true - password_required_characters: - type: string - enum: - - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ - - '' - - null - nullable: true - security_manual_linking_enabled: - type: boolean - nullable: true - security_update_password_require_reauthentication: - type: boolean - nullable: true - security_update_password_require_current_password: - type: boolean - description: Require the user's current password when updating their password. - nullable: true - security_refresh_token_reuse_interval: - type: integer - minimum: 0 - maximum: 2147483647 - description: >- - Refresh token reuse interval in seconds. Maximum 300 seconds (5 - minutes). - nullable: true - mailer_otp_exp: - type: integer - minimum: 0 - maximum: 2147483647 - mailer_otp_length: - type: integer - minimum: 6 - maximum: 10 - nullable: true - sms_autoconfirm: - type: boolean - nullable: true - sms_max_frequency: - type: integer - minimum: 0 - maximum: 32767 - nullable: true - sms_otp_exp: - type: integer - minimum: 0 - maximum: 2147483647 - nullable: true - sms_otp_length: - type: integer - minimum: 0 - maximum: 32767 - sms_provider: - type: string - enum: - - messagebird - - textlocal - - twilio - - twilio_verify - - vonage - - null - nullable: true - sms_messagebird_access_key: - type: string - nullable: true - sms_messagebird_originator: - type: string - nullable: true - sms_test_otp: - type: string - pattern: ^([0-9]{1,15}=[0-9]+,?)*$ - nullable: true - sms_test_otp_valid_until: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - nullable: true - sms_textlocal_api_key: - type: string - nullable: true - sms_textlocal_sender: - type: string - nullable: true - sms_twilio_account_sid: - type: string - nullable: true - sms_twilio_auth_token: - type: string - nullable: true - sms_twilio_content_sid: - type: string - nullable: true - sms_twilio_message_service_sid: - type: string - nullable: true - sms_twilio_verify_account_sid: - type: string - nullable: true - sms_twilio_verify_auth_token: - type: string - nullable: true - sms_twilio_verify_message_service_sid: - type: string - nullable: true - sms_vonage_api_key: - type: string - nullable: true - sms_vonage_api_secret: - type: string - nullable: true - sms_vonage_from: - type: string - nullable: true - sms_template: - type: string - nullable: true - hook_mfa_verification_attempt_enabled: - type: boolean - nullable: true - hook_mfa_verification_attempt_uri: - type: string - nullable: true - hook_mfa_verification_attempt_secrets: - type: string - nullable: true - hook_password_verification_attempt_enabled: - type: boolean - nullable: true - hook_password_verification_attempt_uri: - type: string - nullable: true - hook_password_verification_attempt_secrets: - type: string - nullable: true - hook_custom_access_token_enabled: - type: boolean - nullable: true - hook_custom_access_token_uri: - type: string - nullable: true - hook_custom_access_token_secrets: - type: string - nullable: true - hook_send_sms_enabled: - type: boolean - nullable: true - hook_send_sms_uri: - type: string - nullable: true - hook_send_sms_secrets: - type: string - nullable: true - hook_send_email_enabled: - type: boolean - nullable: true - hook_send_email_uri: - type: string - nullable: true - hook_send_email_secrets: - type: string - nullable: true - hook_before_user_created_enabled: - type: boolean - nullable: true - hook_before_user_created_uri: - type: string - nullable: true - hook_before_user_created_secrets: - type: string - nullable: true - hook_after_user_created_enabled: - type: boolean - nullable: true - hook_after_user_created_uri: - type: string - nullable: true - hook_after_user_created_secrets: - type: string - nullable: true - external_apple_enabled: - type: boolean - nullable: true - external_apple_client_id: - type: string - nullable: true - external_apple_email_optional: - type: boolean - nullable: true - external_apple_secret: - type: string - nullable: true - external_apple_additional_client_ids: - type: string - nullable: true - external_azure_enabled: - type: boolean - nullable: true - external_azure_client_id: - type: string - nullable: true - external_azure_email_optional: - type: boolean - nullable: true - external_azure_secret: - type: string - nullable: true - external_azure_url: - type: string - nullable: true - external_bitbucket_enabled: - type: boolean - nullable: true - external_bitbucket_client_id: - type: string - nullable: true - external_bitbucket_email_optional: - type: boolean - nullable: true - external_bitbucket_secret: - type: string - nullable: true - external_discord_enabled: - type: boolean - nullable: true - external_discord_client_id: - type: string - nullable: true - external_discord_email_optional: - type: boolean - nullable: true - external_discord_secret: - type: string - nullable: true - external_facebook_enabled: - type: boolean - nullable: true - external_facebook_client_id: - type: string - nullable: true - external_facebook_email_optional: - type: boolean - nullable: true - external_facebook_secret: - type: string - nullable: true - external_figma_enabled: - type: boolean - nullable: true - external_figma_client_id: - type: string - nullable: true - external_figma_email_optional: - type: boolean - nullable: true - external_figma_secret: - type: string - nullable: true - external_github_enabled: - type: boolean - nullable: true - external_github_client_id: - type: string - nullable: true - external_github_email_optional: - type: boolean - nullable: true - external_github_secret: - type: string - nullable: true - external_gitlab_enabled: - type: boolean - nullable: true - external_gitlab_client_id: - type: string - nullable: true - external_gitlab_email_optional: - type: boolean - nullable: true - external_gitlab_secret: - type: string - nullable: true - external_gitlab_url: - type: string - nullable: true - external_google_enabled: - type: boolean - nullable: true - external_google_client_id: - type: string - nullable: true - external_google_email_optional: - type: boolean - nullable: true - external_google_secret: - type: string - nullable: true - external_google_additional_client_ids: - type: string - nullable: true - external_google_skip_nonce_check: - type: boolean - nullable: true - external_kakao_enabled: - type: boolean - nullable: true - external_kakao_client_id: - type: string - nullable: true - external_kakao_email_optional: - type: boolean - nullable: true - external_kakao_secret: - type: string - nullable: true - external_keycloak_enabled: - type: boolean - nullable: true - external_keycloak_client_id: - type: string - nullable: true - external_keycloak_email_optional: - type: boolean - nullable: true - external_keycloak_secret: - type: string - nullable: true - external_keycloak_url: - type: string - nullable: true - external_linkedin_oidc_enabled: - type: boolean - nullable: true - external_linkedin_oidc_client_id: - type: string - nullable: true - external_linkedin_oidc_email_optional: - type: boolean - nullable: true - external_linkedin_oidc_secret: - type: string - nullable: true - external_slack_oidc_enabled: - type: boolean - nullable: true - external_slack_oidc_client_id: - type: string - nullable: true - external_slack_oidc_email_optional: - type: boolean - nullable: true - external_slack_oidc_secret: - type: string - nullable: true - external_notion_enabled: - type: boolean - nullable: true - external_notion_client_id: - type: string - nullable: true - external_notion_email_optional: - type: boolean - nullable: true - external_notion_secret: - type: string - nullable: true - external_slack_enabled: - type: boolean - nullable: true - external_slack_client_id: - type: string - nullable: true - external_slack_email_optional: - type: boolean - nullable: true - external_slack_secret: - type: string - nullable: true - external_spotify_enabled: - type: boolean - nullable: true - external_spotify_client_id: - type: string - nullable: true - external_spotify_email_optional: - type: boolean - nullable: true - external_spotify_secret: - type: string - nullable: true - external_twitch_enabled: - type: boolean - nullable: true - external_twitch_client_id: - type: string - nullable: true - external_twitch_email_optional: - type: boolean - nullable: true - external_twitch_secret: - type: string - nullable: true - external_twitter_enabled: - type: boolean - nullable: true - external_twitter_client_id: - type: string - nullable: true - external_twitter_email_optional: - type: boolean - nullable: true - external_twitter_secret: - type: string - nullable: true - external_x_enabled: - type: boolean - nullable: true - external_x_client_id: - type: string - nullable: true - external_x_email_optional: - type: boolean - nullable: true - external_x_secret: - type: string - nullable: true - external_workos_enabled: - type: boolean - nullable: true - external_workos_client_id: - type: string - nullable: true - external_workos_secret: - type: string - nullable: true - external_workos_url: - type: string - nullable: true - external_web3_solana_enabled: - type: boolean - nullable: true - external_web3_ethereum_enabled: - type: boolean - nullable: true - external_zoom_enabled: - type: boolean - nullable: true - external_zoom_client_id: - type: string - nullable: true - external_zoom_email_optional: - type: boolean - nullable: true - external_zoom_secret: - type: string - nullable: true - db_max_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - db_max_pool_size_unit: - type: string - enum: - - connections - - percent - - null - nullable: true - api_max_request_duration: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - mfa_totp_enroll_enabled: - type: boolean - nullable: true - mfa_totp_verify_enabled: - type: boolean - nullable: true - mfa_web_authn_enroll_enabled: - type: boolean - nullable: true - mfa_web_authn_verify_enabled: - type: boolean - nullable: true - passkey_enabled: - type: boolean - webauthn_rp_display_name: - type: string - nullable: true - webauthn_rp_id: - type: string - nullable: true - webauthn_rp_origins: - type: string - nullable: true - mfa_phone_enroll_enabled: - type: boolean - nullable: true - mfa_phone_verify_enabled: - type: boolean - nullable: true - mfa_phone_max_frequency: - type: integer - minimum: 0 - maximum: 32767 - nullable: true - mfa_phone_otp_length: - type: integer - minimum: 0 - maximum: 32767 - nullable: true - mfa_phone_template: - type: string - nullable: true - nimbus_oauth_client_id: - type: string - nullable: true - nimbus_oauth_client_secret: - type: string - nullable: true - oauth_server_enabled: - type: boolean - nullable: true - oauth_server_allow_dynamic_registration: - type: boolean - nullable: true - oauth_server_authorization_path: - type: string - nullable: true - custom_oauth_enabled: - type: boolean - example: - site_url: https://app.example.com - disable_signup: false - jwt_exp: 3600 - CreateThirdPartyAuthBody: - type: object - properties: - oidc_issuer_url: - type: string - jwks_url: - type: string - custom_jwks: {} - example: - oidc_issuer_url: https://login.acme.com - jwks_url: https://login.acme.com/.well-known/jwks.json - ThirdPartyAuth_Output: - type: object - properties: - id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - type: - type: string - oidc_issuer_url: - type: string - nullable: true - jwks_url: - type: string - nullable: true - custom_jwks: - nullable: true - resolved_jwks: - nullable: true - inserted_at: - type: string - updated_at: - type: string - resolved_at: - type: string - nullable: true - required: - - id - - type - - inserted_at - - updated_at - GetProjectAvailableRestoreVersionsResponse_Output: - type: object - properties: - available_versions: - type: array - items: - type: object - properties: - version: - type: string - release_channel: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - postgres_engine: - type: string - enum: - - '13' - - '14' - - '15' - - '17' - - 17-oriole - required: - - version - - release_channel - - postgres_engine - required: - - available_versions - ListProjectAddonsResponse_Output: - type: object - properties: - selected_addons: - type: array - items: - type: object - properties: - type: - type: string - enum: - - custom_domain - - compute_instance - - pitr - - ipv4 - - auth_mfa_phone - - auth_mfa_web_authn - - log_drain - - etl_pipeline - variant: - type: object - properties: - id: - anyOf: - - type: string - enum: - - ci_micro - - ci_small - - ci_medium - - ci_large - - ci_xlarge - - ci_2xlarge - - ci_4xlarge - - ci_8xlarge - - ci_12xlarge - - ci_16xlarge - - ci_24xlarge - - ci_24xlarge_optimized_cpu - - ci_24xlarge_optimized_memory - - ci_24xlarge_high_memory - - ci_48xlarge - - ci_48xlarge_optimized_cpu - - ci_48xlarge_optimized_memory - - ci_48xlarge_high_memory - - type: string - enum: - - cd_default - - type: string - enum: - - pitr_7 - - pitr_14 - - pitr_28 - - type: string - enum: - - ipv4_default - - type: string - enum: - - auth_mfa_phone_default - - type: string - enum: - - auth_mfa_web_authn_default - - type: string - enum: - - log_drain_default - - type: string - enum: - - etl_pipeline_default - name: - type: string - price: - type: object - properties: - description: - type: string - type: - type: string - enum: - - fixed - - usage - interval: - type: string - enum: - - monthly - - hourly - amount: - type: number - required: - - description - - type - - interval - - amount - meta: - $ref: '#/components/schemas/JsonValue_Output' - required: - - id - - name - - price - required: - - type - - variant - available_addons: - type: array - items: - type: object - properties: - type: - type: string - enum: - - custom_domain - - compute_instance - - pitr - - ipv4 - - auth_mfa_phone - - auth_mfa_web_authn - - log_drain - - etl_pipeline - name: - type: string - variants: - type: array - items: - type: object - properties: - id: - anyOf: - - type: string - enum: - - ci_micro - - ci_small - - ci_medium - - ci_large - - ci_xlarge - - ci_2xlarge - - ci_4xlarge - - ci_8xlarge - - ci_12xlarge - - ci_16xlarge - - ci_24xlarge - - ci_24xlarge_optimized_cpu - - ci_24xlarge_optimized_memory - - ci_24xlarge_high_memory - - ci_48xlarge - - ci_48xlarge_optimized_cpu - - ci_48xlarge_optimized_memory - - ci_48xlarge_high_memory - - type: string - enum: - - cd_default - - type: string - enum: - - pitr_7 - - pitr_14 - - pitr_28 - - type: string - enum: - - ipv4_default - - type: string - enum: - - auth_mfa_phone_default - - type: string - enum: - - auth_mfa_web_authn_default - - type: string - enum: - - log_drain_default - - type: string - enum: - - etl_pipeline_default - name: - type: string - price: - type: object - properties: - description: - type: string - type: - type: string - enum: - - fixed - - usage - interval: - type: string - enum: - - monthly - - hourly - amount: - type: number - required: - - description - - type - - interval - - amount - meta: - $ref: '#/components/schemas/JsonValue_Output' - required: - - id - - name - - price - required: - - type - - name - - variants - required: - - selected_addons - - available_addons - ApplyProjectAddonBody: - type: object - properties: - addon_variant: - anyOf: - - type: string - enum: - - ci_micro - - ci_small - - ci_medium - - ci_large - - ci_xlarge - - ci_2xlarge - - ci_4xlarge - - ci_8xlarge - - ci_12xlarge - - ci_16xlarge - - ci_24xlarge - - ci_24xlarge_optimized_cpu - - ci_24xlarge_optimized_memory - - ci_24xlarge_high_memory - - ci_48xlarge - - ci_48xlarge_optimized_cpu - - ci_48xlarge_optimized_memory - - ci_48xlarge_high_memory - - type: string - enum: - - cd_default - - type: string - enum: - - pitr_7 - - pitr_14 - - pitr_28 - - type: string - enum: - - ipv4_default - addon_type: - type: string - enum: - - custom_domain - - compute_instance - - pitr - - ipv4 - - auth_mfa_phone - - auth_mfa_web_authn - - log_drain - - etl_pipeline - required: - - addon_variant - - addon_type - example: - addon_variant: pitr_7 - addon_type: pitr - ProjectClaimTokenResponse_Output: - type: object - properties: - token_alias: - type: string - expires_at: - type: string - created_at: - type: string - created_by: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - required: - - token_alias - - expires_at - - created_at - - created_by - CreateProjectClaimTokenResponse_Output: - type: object - properties: - token: - type: string - token_alias: - type: string - expires_at: - type: string - created_at: - type: string - created_by: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - required: - - token - - token_alias - - expires_at - - created_at - - created_by - V1ProjectAdvisorsResponse_Output: - type: object - properties: - lints: - type: array - items: - type: object - properties: - name: - type: string - enum: - - unindexed_foreign_keys - - auth_users_exposed - - auth_rls_initplan - - no_primary_key - - unused_index - - multiple_permissive_policies - - policy_exists_rls_disabled - - rls_enabled_no_policy - - duplicate_index - - security_definer_view - - function_search_path_mutable - - rls_disabled_in_public - - extension_in_public - - rls_references_user_metadata - - materialized_view_in_api - - foreign_table_in_api - - unsupported_reg_types - - auth_otp_long_expiry - - auth_otp_short_length - - ssl_not_enforced - - log_connections_not_enabled - - network_restrictions_not_set - - password_requirements_min_length - - pitr_not_enabled - - auth_leaked_password_protection - - auth_insufficient_mfa_options - - auth_password_policy_missing - - leaked_service_key - - no_backup_admin - - vulnerable_postgres_version - - db_not_reachable - - db_connection_failing - - db_connection_limit_reached - - instance_telemetry_lost - - instance_db_down - - instance_alert_firing - - log_data_api_error_rate_high - - log_auth_error_rate_high - - log_storage_error_rate_high - - log_edge_function_error_rate_high - - realtime_authorization_timeouts - - realtime_postgres_changes_spike - - project_not_active - - advisor_check_unavailable - title: - type: string - level: - type: string - enum: - - ERROR - - WARN - - INFO - facing: - type: string - enum: - - EXTERNAL - categories: - type: array - items: - type: string - enum: - - PERFORMANCE - - SECURITY - - HEALTH - x-ignore-array-items-must-be-objects: true - description: - type: string - detail: - type: string - remediation: - type: string - metadata: - type: object - properties: - schema: - type: string - name: - type: string - entity: - type: string - type: - type: string - enum: - - table - - view - - materialized view - - foreign table - - auth - - function - - extension - - compliance - - health - fkey_name: - type: string - fkey_columns: - x-ignore-array-items-must-be-objects: true - type: array - items: - type: number - cache_key: - type: string - observed_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ - required: - - name - - title - - level - - facing - - categories - - description - - detail - - remediation - - cache_key - required: - - lints - AnalyticsResponse_Output: - type: object - properties: - result: - type: array - items: {} - error: - anyOf: - - type: string - - type: object - properties: - code: - type: number - errors: - type: array - items: - type: object - properties: - domain: - type: string - location: - type: string - locationType: - type: string - message: - type: string - reason: - type: string - required: - - domain - - location - - locationType - - message - - reason - message: - type: string - status: - type: string - required: - - code - - errors - - message - - status - V1GetUsageApiCountResponse_Output: - type: object - properties: - result: - type: array - items: - type: object - properties: - timestamp: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|))$ - total_auth_requests: - type: number - total_realtime_requests: - type: number - total_rest_requests: - type: number - total_storage_requests: - type: number - required: - - timestamp - - total_auth_requests - - total_realtime_requests - - total_rest_requests - - total_storage_requests - error: - anyOf: - - type: string - - type: object - properties: - code: - type: number - errors: - type: array - items: - type: object - properties: - domain: - type: string - location: - type: string - locationType: - type: string - message: - type: string - reason: - type: string - required: - - domain - - location - - locationType - - message - - reason - message: - type: string - status: - type: string - required: - - code - - errors - - message - - status - V1GetUsageApiRequestsCountResponse_Output: - type: object - properties: - result: - type: array - items: - type: object - properties: - count: - type: number - required: - - count - error: - anyOf: - - type: string - - type: object - properties: - code: - type: number - errors: - type: array - items: - type: object - properties: - domain: - type: string - location: - type: string - locationType: - type: string - message: - type: string - reason: - type: string - required: - - domain - - location - - locationType - - message - - reason - message: - type: string - status: - type: string - required: - - code - - errors - - message - - status - CreateRoleBody: - type: object - properties: - read_only: - type: boolean - required: - - read_only - example: - read_only: true - CreateRoleResponse_Output: - type: object - properties: - role: - type: string - minLength: 1 - password: - type: string - minLength: 1 - ttl_seconds: - type: integer - minimum: 1 - maximum: 9007199254740991 - format: int64 - required: - - role - - password - - ttl_seconds - DeleteRolesResponse_Output: - type: object - properties: - message: - type: string - enum: - - ok - required: - - message - V1ListMigrationsResponse_Output: - type: array - items: - type: object - properties: - version: - type: string - name: - type: string - required: - - version - V1CreateMigrationBody: - type: object - properties: - query: - type: string - minLength: 1 - name: - type: string - rollback: - type: string - required: - - query - example: - query: create table public.widgets(id bigint primary key); - name: create_widgets_table - rollback: drop table if exists public.widgets; - V1UpsertMigrationBody: - type: object - properties: - query: - type: string - minLength: 1 - name: - type: string - rollback: - type: string - required: - - query - example: - query: create table public.widgets(id bigint primary key); - name: create_widgets_table - rollback: drop table if exists public.widgets; - V1GetMigrationResponse_Output: - type: object - properties: - version: - type: string - name: - type: string - statements: - type: array - items: - type: string - rollback: - type: array - items: - type: string - created_by: - type: string - idempotency_key: - type: string - required: - - version - V1PatchMigrationBody: - type: object - properties: - name: - type: string - rollback: - type: string - example: - name: create_widgets_table - rollback: drop table if exists public.widgets; - V1RunQueryBody: - type: object - properties: - query: - type: string - minLength: 1 - parameters: - type: array - items: {} - read_only: - type: boolean - required: - - query - example: - query: select * from pg_stat_activity limit 1; - read_only: true - V1ReadOnlyQueryBody: - type: object - properties: - query: - type: string - minLength: 1 - parameters: - type: array - items: {} - required: - - query - example: - query: select * from pg_stat_activity limit 1; - GetProjectDbMetadataResponse_Output: - type: object - properties: - databases: - type: array - items: - type: object - properties: - name: - type: string - schemas: - type: array - items: - type: object - properties: - name: - type: string - required: - - name - required: - - name - - schemas - required: - - databases - V1UpdatePasswordBody: - type: object - properties: - password: - type: string - minLength: 4 - required: - - password - example: - password: correct-horse-battery-staple - V1UpdatePasswordResponse_Output: - type: object - properties: - message: - type: string - required: - - message - JitAccessResponse_Output: - type: object - properties: - user_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - user_roles: - type: array - items: - type: object - properties: - role: - type: string - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - user_roles - AuthorizeJitAccessBody: - type: object - properties: - role: - type: string - minLength: 1 - rhost: - anyOf: - - type: string - format: ipv4 - pattern: ^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$ - - type: string - format: ipv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$ - required: - - role - - rhost - example: - role: postgres - rhost: 203.0.113.10 - JitAuthorizeAccessResponse_Output: - type: object - properties: - user_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - act: - type: string - user_role: - type: object - properties: - role: - type: string - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - user_role - JitListAccessResponse_Output: - type: object - properties: - items: - type: array - items: - anyOf: - - type: object - properties: - user_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - primary_email: - type: string - nullable: true - invite_id: - type: 'null' - expires_at: - type: 'null' - user_roles: - type: array - items: - type: object - properties: - role: - type: string - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - user_id - - primary_email - - invite_id - - expires_at - - user_roles - - type: object - properties: - user_id: - type: 'null' - primary_email: - type: string - invite_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - expires_at: - type: string - user_roles: - type: array - items: - type: object - properties: - role: - type: string - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - user_id - - primary_email - - invite_id - - expires_at - - user_roles - required: - - items - UpdateJitAccessBody: - type: object - properties: - user_id: - type: string - minLength: 1 - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - roles: - type: array - items: - type: object - properties: - role: - type: string - minLength: 1 - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - user_id - - roles - example: - user_id: 55555555-5555-4555-8555-555555555555 - roles: - - role: postgres - expires_at: 1740787200 - allowed_networks: - allowed_cidrs: - - cidr: 203.0.113.0/24 - branches_only: false - InviteExternalUserJitAccessBody: - type: object - properties: - email: - type: string - minLength: 1 - format: email - pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - roles: - type: array - items: - type: object - properties: - role: - type: string - minLength: 1 - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - email - - roles - example: - email: external-user@somedomain.xyz - roles: - - role: postgres - expires_at: 1740787200 - allowed_networks: - allowed_cidrs: - - cidr: 203.0.113.0/24 - branches_only: false - InviteExternalUserJitResponse_Output: - type: object - properties: - email: - type: string - format: email - pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - invite_id: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - user_roles: - type: array - items: - type: object - properties: - role: - type: string - expires_at: - type: number - allowed_networks: - type: object - properties: - allowed_cidrs: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv4 - pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ - required: - - cidr - allowed_cidrs_v6: - type: array - items: - type: object - properties: - cidr: - type: string - format: cidrv6 - pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ - required: - - cidr - branches_only: - type: boolean - required: - - role - required: - - email - - invite_id - - user_roles - AcceptInviteExternalUserJitAccessBody: - type: object - properties: - email: - type: string - minLength: 1 - format: email - pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - token: - type: string - minLength: 1 - required: - - email - - token - example: - email: external-user@somedomain.xyz - token: '' - FunctionResponse_Output: - type: object - properties: - id: - type: string - slug: - type: string - name: - type: string - status: - type: string - enum: - - ACTIVE - - REMOVED - - THROTTLED - version: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - created_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - updated_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - verify_jwt: - type: boolean - import_map: - type: boolean - entrypoint_path: - type: string - import_map_path: - type: string - ezbr_sha256: - type: string - required: - - id - - slug - - name - - status - - version - - created_at - - updated_at - V1CreateFunctionBody: - type: object - properties: - slug: - type: string - pattern: ^[A-Za-z][A-Za-z0-9_-]*$ - name: - type: string - body: - type: string - verify_jwt: - type: boolean - required: - - slug - - name - - body - example: - slug: hello-world - name: Hello World - body: Deno.serve(() => new Response('Hello, world!')) - verify_jwt: true - BulkUpdateFunctionBody: - type: array - items: - type: object - properties: - id: - type: string - slug: - type: string - pattern: ^[A-Za-z][A-Za-z0-9_-]*$ - name: - type: string - status: - type: string - enum: - - ACTIVE - - REMOVED - - THROTTLED - version: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - created_at: - type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 - verify_jwt: - type: boolean - import_map: - type: boolean - entrypoint_path: - type: string - import_map_path: - type: string - ezbr_sha256: - type: string - required: - - id - - slug - - name - - status - - version - example: - - id: 3c078cce-ad70-4148-9f37-4da362789053 - slug: hello-world - name: Hello World - status: ACTIVE - version: 2 - verify_jwt: true - entrypoint_path: index.ts - BulkUpdateFunctionResponse_Output: - type: object - properties: - functions: - type: array - items: - type: object - properties: - id: - type: string - slug: - type: string - name: - type: string - status: - type: string - enum: - - ACTIVE - - REMOVED - - THROTTLED - version: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - created_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - updated_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - verify_jwt: - type: boolean - import_map: - type: boolean - entrypoint_path: - type: string - import_map_path: - type: string - ezbr_sha256: - type: string - required: - - id - - slug - - name - - status - - version - - created_at - - updated_at - required: - - functions - FunctionDeployBody: - type: object - properties: - file: - type: array - items: - type: string - format: binary - metadata: - type: object - properties: - entrypoint_path: - type: string - import_map_path: - type: string - static_patterns: - type: array - items: - type: string - verify_jwt: - type: boolean - name: - type: string - required: - - entrypoint_path - required: - - file - - metadata - example: - file: - - ./supabase/functions/hello-world/index.ts - metadata: - entrypoint_path: index.ts - verify_jwt: true - name: Hello World - DeployFunctionResponse_Output: - type: object - properties: - id: - type: string - slug: - type: string - name: - type: string - status: - type: string - enum: - - ACTIVE - - REMOVED - - THROTTLED - version: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - created_at: - type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 - updated_at: - type: integer - format: int64 - minimum: -9007199254740991 - maximum: 9007199254740991 - verify_jwt: - type: boolean - import_map: - type: boolean - entrypoint_path: - type: string - import_map_path: - type: string - ezbr_sha256: - type: string - required: - - id - - slug - - name - - status - - version - FunctionSlugResponse_Output: - type: object - properties: - id: - type: string - slug: - type: string - name: - type: string - status: - type: string - enum: - - ACTIVE - - REMOVED - - THROTTLED - version: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - created_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - updated_at: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - verify_jwt: - type: boolean - import_map: - type: boolean - entrypoint_path: - type: string - import_map_path: - type: string - ezbr_sha256: - type: string - required: - - id - - slug - - name - - status - - version - - created_at - - updated_at - StreamableFile: - type: object - properties: {} - V1UpdateFunctionBodyJson: - type: object - properties: - name: - type: string - body: - type: string - verify_jwt: - type: boolean - example: - name: Hello World - body: Deno.serve(() => new Response('Hello again!')) - verify_jwt: true - V1UpdateFunctionBodyEszip: - type: string - format: binary - contentEncoding: binary - description: >+ - Raw Brotli-compressed ESZIP binary file. - - - **Validation requirements** - The file must start with the four magic - bytes `EZBR` (hex: `45 5A 42 52`). - - - The `ezbr_sha256` query parameter must contain the SHA-256 hash of the - uploaded ESZIP payload. - - - **Example** - - ```javascript - - import { readFile } from 'node:fs/promises'; - - import { brotliCompress } from 'node:zlib'; - - import { promisify } from 'node:util'; - - import { createHash } from 'node:crypto'; - - - const compress = promisify(brotliCompress); - - const marker = Buffer.from('EZBR', 'ascii'); - - - const file = await readFile('./function.eszip'); - - const compressed = await compress(file); - - - const payload = Buffer.concat([marker, compressed]); - - - const ezbr_sha256 = createHash('sha256') - .update(payload) - .digest('hex'); - - console.log(ezbr_sha256); - - ``` - - examples: - - Binary Brotli-compressed ESZIP file - V1StorageBucketResponse_Output: - type: object - properties: - id: - type: string - name: - type: string - owner: - type: string - created_at: - type: string - updated_at: - type: string - public: - type: boolean - required: - - id - - name - - owner - - created_at - - updated_at - - public - DiskResponse_Output: - type: object - properties: - attributes: - anyOf: - - type: object - properties: - iops: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - size_gb: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - throughput_mibps: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - type: - type: string - enum: - - gp3 - required: - - iops - - size_gb - - type - - type: object - properties: - iops: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - size_gb: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - type: - type: string - enum: - - io2 - required: - - iops - - size_gb - - type - last_modified_at: - type: string - required: - - attributes - DiskRequestBody: - type: object - properties: - attributes: - oneOf: - - type: object - properties: - iops: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - size_gb: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - throughput_mibps: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - type: - type: string - enum: - - gp3 - required: - - iops - - size_gb - - type - - type: object - properties: - iops: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - size_gb: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - minimum: 0 - type: - type: string - enum: - - io2 - required: - - iops - - size_gb - - type - required: - - attributes - example: - attributes: - type: gp3 - size_gb: 100 - iops: 3000 - throughput_mibps: 125 - DiskUtilMetricsResponse_Output: - type: object - properties: - timestamp: - type: string - metrics: - type: object - properties: - fs_size_bytes: - type: number - fs_avail_bytes: - type: number - fs_used_bytes: - type: number - required: - - fs_size_bytes - - fs_avail_bytes - - fs_used_bytes - required: - - timestamp - - metrics - DiskAutoscaleConfig_Output: - type: object - properties: - growth_percent: - type: integer - exclusiveMinimum: 0 - maximum: 9007199254740991 - description: Growth percentage for disk autoscaling - nullable: true - min_increment_gb: - type: integer - exclusiveMinimum: 0 - maximum: 9007199254740991 - description: Minimum increment size for disk autoscaling in GB - nullable: true - max_size_gb: - type: integer - exclusiveMinimum: 0 - maximum: 9007199254740991 - description: Maximum limit the disk size will grow to in GB - nullable: true - required: - - growth_percent - - min_increment_gb - - max_size_gb - StorageConfigResponse_Output: - type: object - properties: - fileSizeLimit: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - format: int64 - features: - type: object - properties: - imageTransformation: - type: object - properties: - enabled: - type: boolean - required: - - enabled - s3Protocol: - type: object - properties: - enabled: - type: boolean - required: - - enabled - purgeCache: - type: object - properties: - enabled: - type: boolean - required: - - enabled - icebergCatalog: - type: object - properties: - enabled: - type: boolean - maxNamespaces: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxTables: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxCatalogs: - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - enabled - - maxNamespaces - - maxTables - - maxCatalogs - vectorBuckets: - type: object - properties: - enabled: - type: boolean - maxBuckets: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxIndexes: - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - enabled - - maxBuckets - - maxIndexes - required: - - imageTransformation - - s3Protocol - - purgeCache - - icebergCatalog - - vectorBuckets - capabilities: - type: object - properties: - list_v2: - type: boolean - iceberg_catalog: - type: boolean - object_versioning: - type: boolean - required: - - list_v2 - - iceberg_catalog - - object_versioning - external: - type: object - properties: - upstreamTarget: - type: string - enum: - - main - - canary - required: - - upstreamTarget - migrationVersion: - type: string - nullable: true - required: - - fileSizeLimit - - features - - capabilities - - external - - migrationVersion - UpdateStorageConfigBody: - type: object - properties: - fileSizeLimit: - type: integer - format: int64 - minimum: 0 - maximum: 536870912000 - features: - type: object - properties: - imageTransformation: - type: object - properties: - enabled: - type: boolean - required: - - enabled - s3Protocol: - type: object - properties: - enabled: - type: boolean - required: - - enabled - purgeCache: - type: object - properties: - enabled: - type: boolean - required: - - enabled - icebergCatalog: - type: object - properties: - enabled: - type: boolean - maxNamespaces: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxTables: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxCatalogs: - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - enabled - - maxNamespaces - - maxTables - - maxCatalogs - vectorBuckets: - type: object - properties: - enabled: - type: boolean - maxBuckets: - type: integer - minimum: 0 - maximum: 9007199254740991 - maxIndexes: - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - enabled - - maxBuckets - - maxIndexes - external: - type: object - properties: - upstreamTarget: - type: string - enum: - - main - - canary - required: - - upstreamTarget - example: - fileSizeLimit: 10485760 - features: - imageTransformation: - enabled: true - additionalProperties: false - V1PgbouncerConfigResponse_Output: - type: object - properties: - default_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - ignore_startup_parameters: - type: string - max_client_conn: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - pool_mode: - type: string - enum: - - transaction - - session - - statement - connection_string: - type: string - server_idle_timeout: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - server_lifetime: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - query_wait_timeout: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - reserve_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - SupavisorConfigResponse_Output: - type: object - properties: - identifier: - type: string - database_type: - type: string - enum: - - PRIMARY - - READ_REPLICA - is_using_scram_auth: - type: boolean - db_user: - type: string - db_host: - type: string - db_port: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - db_name: - type: string - connection_string: - type: string - connectionString: - type: string - description: Use connection_string instead - default_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - max_client_conn: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - pool_mode: - type: string - enum: - - transaction - - session - required: - - identifier - - database_type - - is_using_scram_auth - - db_user - - db_host - - db_port - - db_name - - connection_string - - connectionString - - default_pool_size - - max_client_conn - - pool_mode - UpdateSupavisorConfigBody: - type: object - properties: - default_pool_size: - type: integer - minimum: 0 - maximum: 3000 - nullable: true - pool_mode: - description: Dedicated pooler mode for the project - type: string - enum: - - transaction - - session - example: - default_pool_size: 25 - pool_mode: transaction - UpdateSupavisorConfigResponse_Output: - type: object - properties: - default_pool_size: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - nullable: true - pool_mode: - type: string - required: - - default_pool_size - - pool_mode - PostgresConfigResponse_Output: - type: object - properties: - effective_cache_size: - type: string - logical_decoding_work_mem: - type: string - pg_stat_statements.max: - type: integer - minimum: 100 - maximum: 45000 - pg_net.batch_size: - type: integer - minimum: 0 - maximum: 32767 - autovacuum_max_workers: - type: integer - minimum: 1 - maximum: 262143 - autovacuum_work_mem: - type: string - cron.log_statement: - type: boolean - log_autovacuum_min_duration: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - log_checkpoints: - type: boolean - log_connections: - type: boolean - log_disconnections: - type: boolean - log_duration: - type: boolean - log_lock_waits: - type: boolean - log_recovery_conflict_waits: - type: boolean - log_replication_commands: - type: boolean - log_startup_progress_interval: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - log_temp_files: - type: string - maintenance_work_mem: - type: string - track_activity_query_size: - type: string - max_connections: - type: integer - minimum: 1 - maximum: 262143 - max_locks_per_transaction: - type: integer - minimum: 10 - maximum: 2147483640 - max_logical_replication_workers: - type: integer - minimum: 0 - maximum: 262143 - max_parallel_maintenance_workers: - type: integer - minimum: 0 - maximum: 1024 - max_parallel_workers: - type: integer - minimum: 0 - maximum: 1024 - max_parallel_workers_per_gather: - type: integer - minimum: 0 - maximum: 1024 - max_replication_slots: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - max_slot_wal_keep_size: - type: string - max_standby_archive_delay: - type: string - max_standby_streaming_delay: - type: string - max_sync_workers_per_subscription: - type: integer - minimum: 0 - maximum: 262143 - max_wal_size: - type: string - max_wal_senders: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - max_worker_processes: - type: integer - minimum: 0 - maximum: 262143 - session_replication_role: - type: string - enum: - - origin - - replica - - local - shared_buffers: - type: string - statement_timeout: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - track_commit_timestamp: - type: boolean - wal_keep_size: - type: string - wal_sender_timeout: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - work_mem: - type: string - checkpoint_timeout: - type: string - description: 'Default unit: s' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - hot_standby_feedback: - type: boolean - UpdatePostgresConfigBody: - type: object - properties: - effective_cache_size: - type: string - logical_decoding_work_mem: - type: string - pg_stat_statements.max: - type: integer - minimum: 100 - maximum: 45000 - pg_net.batch_size: - type: integer - minimum: 0 - maximum: 32767 - autovacuum_max_workers: - type: integer - minimum: 1 - maximum: 262143 - autovacuum_work_mem: - type: string - cron.log_statement: - type: boolean - log_autovacuum_min_duration: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - log_checkpoints: - type: boolean - log_connections: - type: boolean - log_disconnections: - type: boolean - log_duration: - type: boolean - log_lock_waits: - type: boolean - log_recovery_conflict_waits: - type: boolean - log_replication_commands: - type: boolean - log_startup_progress_interval: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - log_temp_files: - type: string - maintenance_work_mem: - type: string - track_activity_query_size: - type: string - max_connections: - type: integer - minimum: 1 - maximum: 262143 - max_locks_per_transaction: - type: integer - minimum: 10 - maximum: 2147483640 - max_logical_replication_workers: - type: integer - minimum: 0 - maximum: 262143 - max_parallel_maintenance_workers: - type: integer - minimum: 0 - maximum: 1024 - max_parallel_workers: - type: integer - minimum: 0 - maximum: 1024 - max_parallel_workers_per_gather: - type: integer - minimum: 0 - maximum: 1024 - max_replication_slots: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - max_slot_wal_keep_size: - type: string - max_standby_archive_delay: - type: string - max_standby_streaming_delay: - type: string - max_sync_workers_per_subscription: - type: integer - minimum: 0 - maximum: 262143 - max_wal_size: - type: string - max_wal_senders: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - max_worker_processes: - type: integer - minimum: 0 - maximum: 262143 - session_replication_role: - type: string - enum: - - origin - - replica - - local - shared_buffers: - type: string - statement_timeout: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - track_commit_timestamp: - type: boolean - wal_keep_size: - type: string - wal_sender_timeout: - type: string - description: 'Default unit: ms' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - work_mem: - type: string - checkpoint_timeout: - type: string - description: 'Default unit: s' - pattern: ^(-?[0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)?$ - hot_standby_feedback: - type: boolean - restart_database: - type: boolean - example: - max_connections: 120 - shared_buffers: 256MB - work_mem: 4MB - statement_timeout: 60000ms - additionalProperties: false - RealtimeConfigResponse_Output: - type: object - properties: - private_only: - type: boolean - description: Whether to only allow private channels - nullable: true - connection_pool: - type: integer - minimum: 1 - maximum: 100 - description: Sets connection pool size for Realtime Authorization - nullable: true - postgres_changes_pool: - type: integer - minimum: 1 - maximum: 100 - description: >- - Sets connection pool size used to create Postgres Changes - subscriptions - nullable: true - max_concurrent_users: - type: integer - minimum: 1 - maximum: 300000 - description: Sets maximum number of concurrent users rate limit - nullable: true - max_events_per_second: - type: integer - minimum: 1 - maximum: 50000 - description: Sets maximum number of events per second rate per channel limit - nullable: true - max_bytes_per_second: - type: integer - minimum: 1 - maximum: 10000000 - description: Sets maximum number of bytes per second rate per channel limit - nullable: true - max_channels_per_client: - type: integer - minimum: 1 - maximum: 10000 - description: Sets maximum number of channels per client rate limit - nullable: true - max_joins_per_second: - type: integer - minimum: 1 - maximum: 5000 - description: Sets maximum number of joins per second rate limit - nullable: true - max_presence_events_per_second: - type: integer - minimum: 1 - maximum: 5000 - description: Sets maximum number of presence events per second rate limit - nullable: true - max_payload_size_in_kb: - type: integer - minimum: 1 - maximum: 10000 - description: Sets maximum number of payload size in KB rate limit - nullable: true - suspend: - type: boolean - description: >- - Disables the Realtime service for this project when true. Set to - false to re-enable it. - nullable: true - presence_enabled: - type: boolean - description: Whether to enable presence - admin_suspended_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: If set, the Realtime service has been suspended by an admin. - nullable: true - required: - - private_only - - connection_pool - - postgres_changes_pool - - max_concurrent_users - - max_events_per_second - - max_bytes_per_second - - max_channels_per_client - - max_joins_per_second - - max_presence_events_per_second - - max_payload_size_in_kb - - suspend - - presence_enabled - - admin_suspended_at - UpdateRealtimeConfigBody: - type: object - properties: - private_only: - type: boolean - description: Whether to only allow private channels - connection_pool: - type: integer - minimum: 1 - maximum: 100 - description: Sets connection pool size for Realtime Authorization - postgres_changes_pool: - type: integer - minimum: 1 - maximum: 100 - description: >- - Sets connection pool size used to create Postgres Changes - subscriptions - max_concurrent_users: - type: integer - minimum: 1 - maximum: 300000 - description: Sets maximum number of concurrent users rate limit - max_events_per_second: - type: integer - minimum: 1 - maximum: 50000 - description: Sets maximum number of events per second rate per channel limit - max_bytes_per_second: - type: integer - minimum: 1 - maximum: 10000000 - description: Sets maximum number of bytes per second rate per channel limit - max_channels_per_client: - type: integer - minimum: 1 - maximum: 10000 - description: Sets maximum number of channels per client rate limit - max_joins_per_second: - type: integer - minimum: 1 - maximum: 5000 - description: Sets maximum number of joins per second rate limit - max_presence_events_per_second: - type: integer - minimum: 1 - maximum: 5000 - description: Sets maximum number of presence events per second rate limit - max_payload_size_in_kb: - type: integer - minimum: 1 - maximum: 10000 - description: Sets maximum number of payload size in KB rate limit - suspend: - type: boolean - description: >- - Disables the Realtime service for this project when true. Set to - false to re-enable it. - presence_enabled: - type: boolean - description: Whether to enable presence - example: - private_only: false - max_concurrent_users: 1000 - max_channels_per_client: 100 - additionalProperties: false - CreateProviderBody: - type: object - properties: - type: - type: string - enum: - - saml - description: What type of provider will be created - metadata_xml: - type: string - metadata_url: - type: string - domains: - type: array - items: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - type - example: - type: saml - metadata_url: https://sso.acme.com/metadata.xml - domains: - - acme.com - attribute_mapping: - keys: - email: - name: email - first_name: - name: first_name - last_name: - name: last_name - CreateProviderResponse_Output: - type: object - properties: - id: - type: string - saml: - type: object - properties: - entity_id: - type: string - metadata_url: - type: string - metadata_xml: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - entity_id - domains: - type: array - items: - type: object - properties: - domain: - type: string - created_at: - type: string - updated_at: - type: string - created_at: - type: string - updated_at: - type: string - required: - - id - ListProvidersResponse_Output: - type: object - properties: - items: - type: array - items: - type: object - properties: - id: - type: string - saml: - type: object - properties: - entity_id: - type: string - metadata_url: - type: string - metadata_xml: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - entity_id - domains: - type: array - items: - type: object - properties: - domain: - type: string - created_at: - type: string - updated_at: - type: string - created_at: - type: string - updated_at: - type: string - required: - - id - required: - - items - GetProviderResponse_Output: - type: object - properties: - id: - type: string - saml: - type: object - properties: - entity_id: - type: string - metadata_url: - type: string - metadata_xml: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - entity_id - domains: - type: array - items: - type: object - properties: - domain: - type: string - created_at: - type: string - updated_at: - type: string - created_at: - type: string - updated_at: - type: string - required: - - id - UpdateProviderBody: - type: object - properties: - metadata_xml: - type: string - metadata_url: - type: string - domains: - type: array - items: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - example: - metadata_url: https://sso.acme.com/metadata.xml - domains: - - acme.com - - contractors.acme.com - UpdateProviderResponse_Output: - type: object - properties: - id: - type: string - saml: - type: object - properties: - entity_id: - type: string - metadata_url: - type: string - metadata_xml: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - entity_id - domains: - type: array - items: - type: object - properties: - domain: - type: string - created_at: - type: string - updated_at: - type: string - created_at: - type: string - updated_at: - type: string - required: - - id - DeleteProviderResponse_Output: - type: object - properties: - id: - type: string - saml: - type: object - properties: - entity_id: - type: string - metadata_url: - type: string - metadata_xml: - type: string - attribute_mapping: - type: object - properties: - keys: - type: object - additionalProperties: - type: object - properties: - name: - type: string - names: - type: array - items: - type: string - array: - type: boolean - default: - anyOf: - - type: object - properties: {} - - type: number - - type: string - - type: boolean - required: - - keys - name_id_format: - type: string - enum: - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - urn:oasis:names:tc:SAML:2.0:nameid-format:transient - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent - required: - - entity_id - domains: - type: array - items: - type: object - properties: - domain: - type: string - created_at: - type: string - updated_at: - type: string - created_at: - type: string - updated_at: - type: string - required: - - id - V1BackupsResponse_Output: - type: object - properties: - region: - type: string - walg_enabled: - type: boolean - pitr_enabled: - type: boolean - backups: - type: array - items: - type: object - properties: - id: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - is_physical_backup: - type: boolean - status: - type: string - enum: - - COMPLETED - - FAILED - - PENDING - - REMOVED - - ARCHIVED - - CANCELLED - inserted_at: - type: string - required: - - id - - is_physical_backup - - status - - inserted_at - physical_backup_data: - type: object - properties: - earliest_physical_backup_date_unix: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - latest_physical_backup_date_unix: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - required: - - region - - walg_enabled - - pitr_enabled - - backups - - physical_backup_data - V1RestorePitrBody: - type: object - properties: - recovery_time_target_unix: - type: integer - minimum: 0 - maximum: 9007199254740991 - format: int64 - required: - - recovery_time_target_unix - example: - recovery_time_target_unix: 1740787200 - V1RestorePointPostBody: - type: object - properties: - name: - type: string - maxLength: 20 - required: - - name - example: - name: before-upgrade - V1RestorePointResponse: - type: object - properties: - name: - type: string - status: - type: string - enum: - - AVAILABLE - - PENDING - - REMOVED - - FAILED - completed_on: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - nullable: true - required: - - name - - status - - completed_on - V1RestoreBackupBody: - type: object - properties: - id: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - required: - - id - example: - id: 12345 - V1BackupScheduleResponse_Output: - type: object - properties: - schedule_for: - type: string - pattern: ^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?$ - description: 'Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.' - example: '04:00:00' - updated_at: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Timestamp of when the backup schedule was last updated. - example: '2026-05-04T14:40:44+00:00' - required: - - schedule_for - - updated_at - V1UpdateBackupScheduleBody: - type: object - properties: - schedule_for: - type: string - pattern: ^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?$ - description: 'Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.' - example: '04:00:00' - required: - - schedule_for - example: - schedule_for: '04:00:00' - V1UndoBody: - type: object - properties: - name: - type: string - maxLength: 20 - required: - - name - example: - name: before-upgrade - V1ListEntitlementsResponse_Output: - type: object - properties: - entitlements: - type: array - items: - type: object - properties: - feature: - type: object - properties: - key: - type: string - enum: - - instances.compute_update_available_sizes - - instances.read_replicas - - instances.disk_modifications - - instances.high_availability - - instances.orioledb - - replication.etl - - storage.max_file_size - - storage.max_file_size.configurable - - storage.image_transformations - - storage.vector_buckets - - storage.iceberg_catalog - - storage.purge_cache - - security.audit_logs_days - - security.questionnaire - - security.soc2_report - - security.iso27001_certificate - - security.private_link - - security.enforce_mfa - - log.retention_days - - custom_domain - - vanity_subdomain - - ipv4 - - pitr.available_variants - - log_drains - - audit_log_drains - - branching_limit - - branching_persistent - - auth.mfa_phone - - auth.mfa_web_authn - - auth.mfa_enhanced_security - - auth.hooks - - auth.platform.sso - - auth.custom_jwt_template - - auth.saml_2 - - auth.user_sessions - - auth.leaked_password_protection - - auth.advanced_auth_settings - - auth.performance_settings - - auth.password_hibp - - auth.custom_oauth.max_providers - - backup.retention_days - - backup.restore_to_new_project - - backup.schedule - - function.max_count - - function.size_limit_mb - - realtime.max_concurrent_users - - realtime.max_events_per_second - - realtime.max_joins_per_second - - realtime.max_channels_per_client - - realtime.max_bytes_per_second - - realtime.max_presence_events_per_second - - realtime.max_payload_size_in_kb - - project_scoped_roles - - security.member_roles - - project_pausing - - project_cloning - - project_restore_after_expiry - - assistant.advance_model - - integrations.github_connections - - integrations.github_push_webhooks_limit - - dedicated_pooler - - observability.dashboard_advanced_metrics - - api.members.invitations - - api.members.roles - type: - type: string - enum: - - boolean - - numeric - - set - required: - - key - - type - hasAccess: - type: boolean - type: - type: string - enum: - - boolean - - numeric - - set - config: - anyOf: - - type: object - properties: - enabled: - type: boolean - required: - - enabled - - type: object - properties: - enabled: - type: boolean - value: - type: number - unlimited: - type: boolean - unit: - type: string - required: - - enabled - - value - - unlimited - - unit - - type: object - properties: - enabled: - type: boolean - set: - type: array - items: - type: string - required: - - enabled - - set - required: - - feature - - hasAccess - - type - - config - required: - - entitlements - V1OrganizationMemberResponse_Output: - type: object - properties: - user_id: - type: string - user_name: - type: string - email: - type: string - role_name: - type: string - mfa_enabled: - type: boolean - avatar_url: - type: string - nullable: true - required: - - user_id - - user_name - - mfa_enabled - - avatar_url - V1OrganizationSlugResponse_Output: - type: object - properties: - id: - type: string - name: - type: string - plan: - type: string - enum: - - free - - pro - - team - - enterprise - - platform - opt_in_tags: - type: array - items: - enum: - - AI_SQL_GENERATOR_OPT_IN - - AI_DATA_GENERATOR_OPT_IN - - AI_LOG_GENERATOR_OPT_IN - allowed_release_channels: - type: array - items: - type: string - enum: - - internal - - alpha - - beta - - ga - - withdrawn - - preview - required: - - id - - name - - opt_in_tags - - allowed_release_channels - OrganizationProjectClaimResponse_Output: - type: object - properties: - project: - type: object - properties: - ref: - type: string - name: - type: string - required: - - ref - - name - preview: - type: object - properties: - valid: - type: boolean - warnings: - type: array - items: - type: object - properties: - key: - type: string - message: - type: string - required: - - key - - message - errors: - type: array - items: - type: object - properties: - key: - type: string - message: - type: string - required: - - key - - message - info: - type: array - items: - type: object - properties: - key: - type: string - message: - type: string - required: - - key - - message - members_exceeding_free_project_limit: - type: array - items: - type: object - properties: - name: - type: string - limit: - type: number - required: - - name - - limit - source_subscription_plan: - type: string - enum: - - free - - pro - - team - - enterprise - - platform - target_subscription_plan: - type: string - enum: - - free - - pro - - team - - enterprise - - platform - - null - nullable: true - required: - - valid - - warnings - - errors - - info - - members_exceeding_free_project_limit - - source_subscription_plan - - target_subscription_plan - expires_at: - type: string - created_at: - type: string - created_by: - type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ - required: - - project - - preview - - expires_at - - created_at - - created_by - OrganizationProjectsResponse_Output: - type: object - properties: - projects: - type: array - items: - type: object - properties: - ref: - type: string - name: - type: string - cloud_provider: - type: string - region: - type: string - is_branch: - type: boolean - status: - type: string - enum: - - INACTIVE - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - UNKNOWN - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UPGRADING - - PAUSING - - RESTORE_FAILED - - RESTARTING - - PAUSE_FAILED - - RESIZING - inserted_at: - type: string - databases: - type: array - items: - type: object - properties: - infra_compute_size: - type: string - enum: - - pico - - nano - - micro - - small - - medium - - large - - xlarge - - 2xlarge - - 4xlarge - - 8xlarge - - 12xlarge - - 16xlarge - - 24xlarge - - 24xlarge_optimized_memory - - 24xlarge_optimized_cpu - - 24xlarge_high_memory - - 48xlarge - - 48xlarge_optimized_memory - - 48xlarge_optimized_cpu - - 48xlarge_high_memory - region: - type: string - status: - type: string - enum: - - ACTIVE_HEALTHY - - ACTIVE_UNHEALTHY - - COMING_UP - - GOING_DOWN - - INIT_FAILED - - REMOVED - - RESTORING - - UNKNOWN - - INIT_READ_REPLICA - - INIT_READ_REPLICA_FAILED - - RESTARTING - - RESIZING - cloud_provider: - type: string - identifier: - type: string - type: - type: string - enum: - - PRIMARY - - READ_REPLICA - disk_volume_size_gb: - type: number - disk_type: - type: string - enum: - - gp3 - - io2 - disk_throughput_mbps: - type: number - disk_last_modified_at: - type: string - required: - - region - - status - - cloud_provider - - identifier - - type - required: - - ref - - name - - cloud_provider - - region - - is_branch - - status - - inserted_at - - databases - pagination: - type: object - properties: - count: - type: number - description: >- - Total number of projects. Use this to calculate the total number - of pages. - limit: - type: number - description: Maximum number of projects per page - offset: - type: number - description: Number of projects skipped in this response - required: - - count - - limit - - offset - required: - - projects - - pagination diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go deleted file mode 100644 index bf913d2aa3..0000000000 --- a/apps/cli-go/cmd/db.go +++ /dev/null @@ -1,138 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/legacy/branch/create" - "github.com/supabase/cli/legacy/branch/delete" - "github.com/supabase/cli/legacy/branch/list" - "github.com/supabase/cli/legacy/branch/switch_" -) - -var ( - dbCmd = &cobra.Command{ - GroupID: groupLocalDev, - Use: "db", - Short: "Manage Postgres databases", - } - - dbBranchCmd = &cobra.Command{ - Hidden: true, - Use: "branch", - Short: "Manage local database branches", - Long: "Manage local database branches. Each branch is associated with a separate local database. Forking remote databases is NOT supported.", - } - - dbBranchCreateCmd = &cobra.Command{ - Deprecated: "use \"branches create \" instead.\n", - Use: "create ", - Short: "Create a branch", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return create.Run(args[0], afero.NewOsFs()) - }, - } - - dbBranchDeleteCmd = &cobra.Command{ - Deprecated: "use \"branches delete \" instead.\n", - Use: "delete ", - Short: "Delete a branch", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return delete.Run(args[0], afero.NewOsFs()) - }, - } - - dbBranchListCmd = &cobra.Command{ - Deprecated: "use \"branches list\" instead.\n", - Use: "list", - Short: "List branches", - RunE: func(cmd *cobra.Command, args []string) error { - return list.Run(afero.NewOsFs(), os.Stdout) - }, - } - - dbSwitchCmd = &cobra.Command{ - Deprecated: "use \"branches create \" instead.\n", - Use: "switch ", - Short: "Switch the active branch", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return switch_.Run(cmd.Context(), args[0], afero.NewOsFs()) - }, - } - - // Bound so the TS `--use-pg-schema` proxy can forward these without unknown-flag errors. - usePgSchema bool - outputPath string - schema []string - file string - dbPassword string - - dbDiffCmd = &cobra.Command{ - Use: "diff", - Short: "Diffs the local database for schema changes", - RunE: func(cmd *cobra.Command, args []string) error { - // TypeScript only proxies `--use-pg-schema` (stripe/pg-schema-diff). - // Other engines run in-process in the TS CLI. - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "--use-pg-schema flag is experimental and may not include all entities, such as views and grants.") - return diff.Run(cmd.Context(), schema, file, flags.DbConfig, diff.DiffPgSchema, afero.NewOsFs()) - }, - } - - dbRemoteCmd = &cobra.Command{ - Hidden: true, - Use: "remote", - Short: "Manage remote databases", - } - - dbRemoteChangesCmd = &cobra.Command{ - Deprecated: "use \"db diff --use-migra --linked\" instead.\n", - Use: "changes", - Short: "Show changes on the remote database", - Long: "Show changes on the remote database since last migration.", - RunE: func(cmd *cobra.Command, args []string) error { - return diff.Run(cmd.Context(), schema, file, flags.DbConfig, diff.DiffSchemaMigra, afero.NewOsFs()) - }, - } -) - -func init() { - // Build branch command - dbBranchCmd.AddCommand(dbBranchCreateCmd) - dbBranchCmd.AddCommand(dbBranchDeleteCmd) - dbBranchCmd.AddCommand(dbBranchListCmd) - dbBranchCmd.AddCommand(dbSwitchCmd) - dbCmd.AddCommand(dbBranchCmd) - // Build diff command - diffFlags := dbDiffCmd.Flags() - diffFlags.BoolVar(&usePgSchema, "use-pg-schema", false, "Use pg-schema-diff to generate schema diff.") - diffFlags.StringVarP(&outputPath, "output", "o", "", "Write explicit diff output to a file path.") - diffFlags.String("db-url", "", "Diffs against the database specified by the connection string (must be percent-encoded).") - diffFlags.Bool("linked", false, "Diffs local migration files against the linked project.") - diffFlags.Bool("local", true, "Diffs local migration files against the local database.") - dbDiffCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") - diffFlags.StringVarP(&file, "file", "f", "", "Saves schema diff to a new migration file.") - diffFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") - dbCmd.AddCommand(dbDiffCmd) - // Build remote command - remoteFlags := dbRemoteCmd.PersistentFlags() - remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") - remoteFlags.String("db-url", "", "Connect using the specified Postgres URL (must be percent-encoded).") - remoteFlags.Bool("linked", true, "Connect to the linked project.") - dbRemoteCmd.MarkFlagsMutuallyExclusive("db-url", "linked") - remoteFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") - markFlagTelemetrySafe(remoteFlags.Lookup("project-ref")) - remoteFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") - cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", remoteFlags.Lookup("password"))) - dbRemoteCmd.AddCommand(dbRemoteChangesCmd) - dbCmd.AddCommand(dbRemoteCmd) - rootCmd.AddCommand(dbCmd) -} diff --git a/apps/cli-go/cmd/functions.go b/apps/cli-go/cmd/functions.go deleted file mode 100644 index 3434c5ceb8..0000000000 --- a/apps/cli-go/cmd/functions.go +++ /dev/null @@ -1,51 +0,0 @@ -package cmd - -import ( - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/supabase/cli/internal/functions/download" - "github.com/supabase/cli/internal/utils/flags" -) - -var ( - functionsCmd = &cobra.Command{ - GroupID: groupManagementAPI, - Use: "functions", - Short: "Manage Supabase Edge functions", - } - - functionsDownloadCmd = &cobra.Command{ - Use: "download [Function name]", - Short: "Download a Function from Supabase", - Long: "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if useApi { - useDocker = false - } - slug := "" - if len(args) > 0 { - slug = args[0] - } - return download.Run(cmd.Context(), slug, flags.ProjectRef, useLegacyBundle, useDocker, afero.NewOsFs()) - }, - } - - useApi bool - useDocker bool - useLegacyBundle bool -) - -func init() { - downloadFlags := functionsDownloadCmd.Flags() - downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") - markFlagTelemetrySafe(downloadFlags.Lookup("project-ref")) - downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.") - downloadFlags.BoolVar(&useApi, "use-api", false, "Unbundle functions server-side without using Docker.") - downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.") - functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle") - cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle")) - cobra.CheckErr(downloadFlags.MarkHidden("use-docker")) - functionsCmd.AddCommand(functionsDownloadCmd) - rootCmd.AddCommand(functionsCmd) -} diff --git a/apps/cli-go/cmd/gen.go b/apps/cli-go/cmd/gen.go deleted file mode 100644 index 9ccdbc862d..0000000000 --- a/apps/cli-go/cmd/gen.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - env "github.com/Netflix/go-env" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/legacy/keys" -) - -var ( - genCmd = &cobra.Command{ - GroupID: groupLocalDev, - Use: "gen", - Short: "Run code generation tools", - } - - keyNames keys.CustomName - override []string - - genKeysCmd = &cobra.Command{ - Deprecated: `use "gen signing-key" instead.`, - Use: "keys", - Short: "Generate keys for preview branch", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - es, err := env.EnvironToEnvSet(override) - if err != nil { - return err - } - if err := env.Unmarshal(es, &keyNames); err != nil { - return err - } - cmd.GroupID = groupManagementAPI - return cmd.Root().PersistentPreRunE(cmd, args) - }, - RunE: func(cmd *cobra.Command, args []string) error { - format := utils.OutputFormat.Value - if format == utils.OutputPretty { - format = utils.OutputEnv - } - return keys.Run(cmd.Context(), flags.ProjectRef, format, keyNames, afero.NewOsFs()) - }, - } -) - -func init() { - keyFlags := genKeysCmd.Flags() - keyFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") - markFlagTelemetrySafe(keyFlags.Lookup("project-ref")) - keyFlags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.") - genCmd.AddCommand(genKeysCmd) - rootCmd.AddCommand(genCmd) -} diff --git a/apps/cli-go/cmd/root.go b/apps/cli-go/cmd/root.go deleted file mode 100644 index 1c48ab558c..0000000000 --- a/apps/cli-go/cmd/root.go +++ /dev/null @@ -1,380 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "net" - "net/http" - "net/url" - "os" - "os/signal" - "strconv" - "strings" - "sync" - "time" - - "github.com/getsentry/sentry-go" - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/debug" - "github.com/supabase/cli/internal/telemetry" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/config" - "golang.org/x/mod/semver" -) - -const ( - groupQuickStart = "quick-start" - groupLocalDev = "local-dev" - groupManagementAPI = "management-api" -) - -func IsManagementAPI(cmd *cobra.Command) bool { - for cmd != cmd.Root() { - if cmd.GroupID == groupManagementAPI { - return true - } - // Find the last assigned group - if len(cmd.GroupID) > 0 { - break - } - cmd = cmd.Parent() - } - return false -} - -func promptLogin(fsys afero.Fs) error { - if _, err := utils.LoadAccessTokenFS(fsys); err == utils.ErrMissingToken { - utils.CmdSuggestion = fmt.Sprintf("Run %s first.", utils.Aqua("supabase login")) - return errors.New("You need to be logged-in in order to use Management API commands.") - } else { - return err - } -} - -var experimental = []*cobra.Command{ - genKeysCmd, -} - -func IsExperimental(cmd *cobra.Command) bool { - for _, exp := range experimental { - if cmd == exp || cmd.Parent() == exp { - return true - } - } - return false -} - -var ( - sentryOpts = sentry.ClientOptions{ - Dsn: utils.SentryDsn, - Release: utils.Version, - ServerName: "", - // Set TracesSampleRate to 1.0 to capture 100% - // of transactions for performance monitoring. - // We recommend adjusting this value in production, - TracesSampleRate: 1.0, - } - - createTicket bool - - rootCmd = &cobra.Command{ - Use: "supabase", - Short: "Supabase CLI " + utils.Version, - Version: utils.Version, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if IsExperimental(cmd) && !viper.GetBool("EXPERIMENTAL") { - return errors.New("must set the --experimental flag to run this command") - } - cmd.SilenceUsage = true - // Load profile before changing workdir - ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) - fsys := afero.NewOsFs() - if err := utils.LoadProfile(ctx, fsys); err != nil { - return err - } - if err := utils.ChangeWorkDir(fsys); err != nil { - return err - } - // Add common flags - if IsManagementAPI(cmd) { - if err := promptLogin(fsys); err != nil { - return err - } - if cmd.Flags().Lookup("project-ref") != nil { - if err := flags.ParseProjectRef(ctx, fsys); err != nil { - return err - } - } - } - if err := flags.ParseDatabaseConfig(ctx, cmd.Flags(), fsys); err != nil { - return err - } - // Prepare context - if viper.GetBool("DEBUG") { - http.DefaultTransport = debug.NewTransport() - fmt.Fprintln(os.Stderr, cmd.Root().Short) - fmt.Fprintf(os.Stderr, "Using profile: %s (%s)\n", utils.CurrentProfile.Name, utils.CurrentProfile.ProjectHost) - } - isTTY := telemetryIsTTY() - isCI := telemetryIsCI() - isAgent := telemetryIsAgent() - envSignals := telemetryEnvSignals() - service, err := telemetry.NewService(fsys, telemetry.Options{ - Now: time.Now, - IsTTY: isTTY, - IsCI: isCI, - IsAgent: isAgent, - EnvSignals: envSignals, - CLIName: utils.Version, - }) - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } else { - ctx = telemetry.WithService(ctx, service) - } - if service != nil { - var stitchOnce sync.Once - utils.OnGotrueID = func(gotrueID string) { - stitchOnce.Do(func() { - if err := service.ObserveAuthenticatedUser(gotrueID); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - }) - } - } - ctx = telemetry.WithCommandContext(ctx, commandAnalyticsContext(cmd)) - cmd.SetContext(ctx) - // Setup sentry last to ignore errors from parsing cli flags - apiHost, err := url.Parse(utils.GetSupabaseAPIHost()) - if err != nil { - return err - } - sentryOpts.Environment = apiHost.Host - return sentry.Init(sentryOpts) - }, - SilenceErrors: true, - } -) - -func Execute() { - defer recoverAndExit() - startedAt := time.Now() - executedCmd, err := rootCmd.ExecuteC() - if executedCmd != nil { - if service := telemetry.FromContext(executedCmd.Context()); service != nil { - ensureProjectGroupsCached(executedCmd.Context(), service) - _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ - telemetry.PropExitCode: exitCode(err), - telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), - telemetry.PropOutputFormat: utils.OutputFormat.Value, - }, nil) - _ = service.Close() - } - } - if err != nil { - panic(err) - } - // Check upgrade last because --version flag is initialised after execute - ctx := rootCmd.Context() - if executedCmd != nil { - ctx = executedCmd.Context() - } - if hint := utils.SuggestClaudePlugin(); hint != "" { - fmt.Fprintln(os.Stderr, hint) - } - if updateNotifierEnabled() { - version, err := checkUpgrade(ctx, afero.NewOsFs()) - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - if semver.Compare(version, "v"+utils.Version) > 0 { - fmt.Fprintln(os.Stderr, suggestUpgrade(version)) - } - } - if len(utils.CmdSuggestion) > 0 { - fmt.Fprintln(os.Stderr, utils.CmdSuggestion) - } -} - -// ensureProjectGroupsCached populates the telemetry linked-project cache when -// a project ref is available but no cache exists. This ensures org/project -// PostHog groups are attached to all CLI events, not just those after `supabase link`. -// -// Does not overwrite an existing cache — `supabase link` is the authoritative source. -// Checks auth before calling the API to avoid the log.Fatalln in GetSupabase(). -func ensureProjectGroupsCached(ctx context.Context, service *telemetry.Service) { - ref := flags.ProjectRef - if ref == "" || config.UsesStackState() { - return - } - fsys := afero.NewOsFs() - if telemetry.HasLinkedProject(fsys) { - return - } - if _, err := utils.LoadAccessTokenFS(fsys); err != nil { - return - } - resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, ref) - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - return - } - if resp.JSON200 == nil { - return - } - telemetry.CacheProjectAndIdentifyGroups(*resp.JSON200, service, fsys) -} - -func exitCode(err error) int { - if err != nil { - return 1 - } - return 0 -} - -func updateNotifierEnabled() bool { - // Read the env directly instead of viper: the upgrade check also runs for - // --help and --version, which skip the cobra initializer that binds env vars. - disabled, _ := strconv.ParseBool(os.Getenv("SUPABASE_NO_UPDATE_NOTIFIER")) - return !disabled && !config.UsesStackState() -} - -func checkUpgrade(ctx context.Context, fsys afero.Fs) (string, error) { - if shouldFetchRelease(fsys) { - version, err := utils.GetLatestRelease(ctx) - if exists, _ := afero.DirExists(fsys, utils.SupabaseDirPath); exists { - // If user is offline, write an empty file to skip subsequent checks - err = utils.WriteFile(utils.CliVersionPath, []byte(version), fsys) - } - return version, err - } - version, err := afero.ReadFile(fsys, utils.CliVersionPath) - if err != nil { - return "", errors.Errorf("failed to read cli version: %w", err) - } - return string(version), nil -} - -func shouldFetchRelease(fsys afero.Fs) bool { - // Always fetch latest release when using --version flag - if vf := rootCmd.Flag("version"); vf != nil && vf.Changed { - return true - } - if fi, err := fsys.Stat(utils.CliVersionPath); err == nil { - expiry := fi.ModTime().Add(time.Hour * 10) - // Skip if last checked is less than 10 hours ago - return time.Now().After(expiry) - } - return true -} - -func suggestUpgrade(version string) string { - const guide = "https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli" - return fmt.Sprintf(`A new version of Supabase CLI is available: %s (currently installed v%s) -We recommend updating regularly for new features and bug fixes: %s`, utils.Yellow(version), utils.Version, utils.Bold(guide)) -} - -func recoverAndExit() { - err := recover() - if err == nil { - return - } - var msg string - switch err := err.(type) { - case string: - msg = err - case error: - if !errors.Is(err, context.Canceled) && - len(utils.CmdSuggestion) == 0 && - !viper.GetBool("DEBUG") { - utils.CmdSuggestion = utils.SuggestDebugFlag - } - if e, ok := err.(*errors.Error); ok && len(utils.Version) == 0 { - fmt.Fprintln(os.Stderr, string(e.Stack())) - } - msg = err.Error() - default: - msg = fmt.Sprintf("%#v", err) - } - // Log error to console - fmt.Fprintln(os.Stderr, utils.Red(msg)) - if len(utils.CmdSuggestion) > 0 { - fmt.Fprintln(os.Stderr, utils.CmdSuggestion) - } - // Report error to sentry - if createTicket && len(utils.SentryDsn) > 0 { - sentry.ConfigureScope(addSentryScope) - eventId := sentry.CurrentHub().Recover(err) - if eventId != nil && sentry.Flush(2*time.Second) { - fmt.Fprintln(os.Stderr, "Sent crash report:", *eventId) - fmt.Fprintln(os.Stderr, "Quote the crash ID above when filing a bug report: https://github.com/supabase/cli/issues/new/choose") - } - } - os.Exit(1) -} - -func init() { - cobra.OnInitialize(func() { - viper.SetEnvPrefix("SUPABASE") - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - viper.AutomaticEnv() - }) - - // Shell tab-completion is fully native in the TS shim now (CLI-1965); the - // TS entrypoint intercepts completion/__complete/__completeNoDesc before - // ever delegating to this binary, so cobra's own completion command is - // unreachable dead weight here. This only removes the visible - // `completion ` command — cobra's ExecuteC() unconditionally - // (re-)registers the hidden __complete/__completeNoDesc responder on - // every run with no opt-out (command.go's initCompleteCmd), so that - // protocol handler stays present but, same as above, unreachable through - // the shipped CLI. - rootCmd.CompletionOptions.DisableDefaultCmd = true - - flags := rootCmd.PersistentFlags() - flags.Bool("yes", false, "answer yes to all prompts") - flags.Bool("debug", false, "output debug logs to stderr") - flags.String("workdir", "", "path to a Supabase project directory") - flags.Bool("experimental", false, "enable experimental features") - flags.String("network-id", "", "use the specified docker network instead of a generated one") - flags.String("profile", "supabase", "use a specific profile for connecting to Supabase API") - flags.VarP(&utils.OutputFormat, "output", "o", "output format of status variables") - flags.Var(&utils.DNSResolver, "dns-resolver", "lookup domain names using the specified resolver") - flags.BoolVar(&createTicket, "create-ticket", false, "create a support ticket for any CLI error") - flags.VarP(&utils.AgentMode, "agent", "", "Override agent detection: yes, no, or auto (default auto)") - cobra.CheckErr(viper.BindPFlags(flags)) - - rootCmd.SetVersionTemplate("{{.Version}}\n") - rootCmd.AddGroup(&cobra.Group{ID: groupQuickStart, Title: "Quick Start:"}) - rootCmd.AddGroup(&cobra.Group{ID: groupLocalDev, Title: "Local Development:"}) - rootCmd.AddGroup(&cobra.Group{ID: groupManagementAPI, Title: "Management APIs:"}) -} - -// instantiate new rootCmd is a bit tricky with cobra, but it can be done later with the following -// approach for example: https://github.com/portworx/pxc/tree/master/cmd -func GetRootCmd() *cobra.Command { - return rootCmd -} - -func addSentryScope(scope *sentry.Scope) { - serviceImages := utils.Config.GetServiceImages() - imageToVersion := make(map[string]any, len(serviceImages)) - for _, image := range serviceImages { - parts := strings.Split(image, ":") - // Bypasses sentry's IP sanitization rule, ie. 15.1.0.147 - if net.ParseIP(parts[1]) != nil { - imageToVersion[parts[0]] = "v" + parts[1] - } else { - imageToVersion[parts[0]] = parts[1] - } - } - scope.SetContext("Services", imageToVersion) - scope.SetContext("Config", map[string]any{ - "Image Registry": utils.GetRegistry(), - "Project ID": flags.ProjectRef, - }) -} diff --git a/apps/cli-go/cmd/root_analytics.go b/apps/cli-go/cmd/root_analytics.go deleted file mode 100644 index bf9735ec3c..0000000000 --- a/apps/cli-go/cmd/root_analytics.go +++ /dev/null @@ -1,180 +0,0 @@ -package cmd - -import ( - "os" - "sort" - "strconv" - "strings" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/supabase/cli/internal/telemetry" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/agent" - "golang.org/x/term" -) - -const ( - telemetrySafeValueAnnotation = "supabase.com/telemetry-safe-value" - redactedTelemetryValue = "" - maxTelemetryEnvValueLength = 80 -) - -func commandAnalyticsContext(cmd *cobra.Command) telemetry.CommandContext { - return telemetry.CommandContext{ - RunID: uuid.NewString(), - Command: commandName(cmd), - Flags: changedFlagValues(cmd), - } -} - -func commandName(cmd *cobra.Command) string { - path := strings.TrimSpace(cmd.CommandPath()) - rootName := strings.TrimSpace(cmd.Root().Name()) - if path == rootName || path == "" { - return rootName - } - return strings.TrimSpace(strings.TrimPrefix(path, rootName)) -} - -func changedFlagValues(cmd *cobra.Command) map[string]any { - flags := changedFlags(cmd) - if len(flags) == 0 { - return nil - } - values := make(map[string]any, len(flags)) - for _, flag := range flags { - values[flag.Name] = telemetryFlagValue(flag) - } - return values -} - -func changedFlags(cmd *cobra.Command) []*pflag.Flag { - seen := make(map[string]struct{}) - var result []*pflag.Flag - collect := func(flags *pflag.FlagSet) { - if flags == nil { - return - } - flags.Visit(func(flag *pflag.Flag) { - if _, ok := seen[flag.Name]; ok { - return - } - seen[flag.Name] = struct{}{} - result = append(result, flag) - }) - } - for current := cmd; current != nil; current = current.Parent() { - collect(current.PersistentFlags()) - } - collect(cmd.Flags()) - sort.Slice(result, func(i, j int) bool { - return result[i].Name < result[j].Name - }) - return result -} - -func markFlagTelemetrySafe(flag *pflag.Flag) { - if flag == nil { - return - } - if flag.Annotations == nil { - flag.Annotations = map[string][]string{} - } - flag.Annotations[telemetrySafeValueAnnotation] = []string{"true"} -} - -func telemetryFlagValue(flag *pflag.Flag) any { - if flag == nil { - return nil - } - if isTelemetrySafeFlag(flag) || isBooleanFlag(flag) || isEnumFlag(flag) { - return actualTelemetryFlagValue(flag) - } - return redactedTelemetryValue -} - -func isTelemetrySafeFlag(flag *pflag.Flag) bool { - if flag == nil || flag.Annotations == nil { - return false - } - values, ok := flag.Annotations[telemetrySafeValueAnnotation] - return ok && len(values) > 0 && values[0] == "true" -} - -func isBooleanFlag(flag *pflag.Flag) bool { - return flag != nil && flag.Value.Type() == "bool" -} - -func isEnumFlag(flag *pflag.Flag) bool { - if flag == nil { - return false - } - _, ok := flag.Value.(*utils.EnumFlag) - return ok -} - -func actualTelemetryFlagValue(flag *pflag.Flag) any { - if isBooleanFlag(flag) { - value, err := strconv.ParseBool(flag.Value.String()) - if err == nil { - return value - } - } - return flag.Value.String() -} - -func telemetryIsCI() bool { - return os.Getenv("CI") != "" || - os.Getenv("GITHUB_ACTIONS") != "" || - os.Getenv("BUILDKITE") != "" || - os.Getenv("TF_BUILD") != "" || - os.Getenv("JENKINS_URL") != "" || - os.Getenv("GITLAB_CI") != "" -} - -func telemetryIsTTY() bool { - return term.IsTerminal(int(os.Stdout.Fd())) //nolint:gosec // G115: stdout fd is a small int on supported platforms -} - -func telemetryIsAgent() bool { - return agent.IsAgent() -} - -func telemetryEnvSignals() map[string]any { - return envSignals(telemetry.EnvSignalPresenceKeys[:], telemetry.EnvSignalValueKeys[:]) -} - -func envSignals(presenceKeys []string, valueKeys []string) map[string]any { - signals := make(map[string]any, len(presenceKeys)+len(valueKeys)) - for _, key := range presenceKeys { - if hasTelemetryEnvValue(key) { - signals[key] = true - } - } - for _, key := range valueKeys { - if value := telemetryEnvValue(key); value != "" { - signals[key] = value - } - } - if len(signals) == 0 { - return nil - } - return signals -} - -func hasTelemetryEnvValue(key string) bool { - return strings.TrimSpace(os.Getenv(key)) != "" -} - -func telemetryEnvValue(key string) string { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return "" - } - if len(value) > maxTelemetryEnvValueLength { - return value[:maxTelemetryEnvValueLength] - } - return value -} diff --git a/apps/cli-go/cmd/root_test.go b/apps/cli-go/cmd/root_test.go deleted file mode 100644 index 8dc63afd48..0000000000 --- a/apps/cli-go/cmd/root_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package cmd - -import ( - "strings" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/telemetry" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -func clearTelemetryEnv(t *testing.T) { - for _, key := range telemetry.EnvSignalPresenceKeys { - t.Setenv(key, "") - } - for _, key := range telemetry.EnvSignalValueKeys { - t.Setenv(key, "") - } -} - -func TestCommandAnalyticsContext(t *testing.T) { - root := &cobra.Command{Use: "supabase"} - var projectRef string - var password string - var debug bool - output := utils.EnumFlag{ - Allowed: []string{"json", "table"}, - Value: "table", - } - child := &cobra.Command{ - Use: "link", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - root.PersistentFlags().BoolVar(&debug, "debug", false, "") - child.Flags().StringVar(&projectRef, "project-ref", "", "") - child.Flags().StringVar(&password, "password", "", "") - child.Flags().Var(&output, "output", "") - child.Flags().AddFlag(root.PersistentFlags().Lookup("debug")) - markFlagTelemetrySafe(child.Flags().Lookup("project-ref")) - root.AddCommand(child) - - require.NoError(t, root.PersistentFlags().Set("debug", "true")) - require.NoError(t, child.Flags().Set("project-ref", "proj_123")) - require.NoError(t, child.Flags().Set("password", "hunter2")) - require.NoError(t, child.Flags().Set("output", "json")) - - ctx := commandAnalyticsContext(child) - - assert.Equal(t, "link", ctx.Command) - assert.Equal(t, map[string]any{ - "debug": true, - "output": "json", - "password": redactedTelemetryValue, - "project-ref": "proj_123", - }, ctx.Flags) - assert.NotContains(t, ctx.Flags, "linked") - assert.NotEmpty(t, ctx.RunID) -} - -func TestCommandName(t *testing.T) { - root := &cobra.Command{Use: "supabase"} - parent := &cobra.Command{Use: "db"} - child := &cobra.Command{Use: "push"} - root.AddCommand(parent) - parent.AddCommand(child) - - assert.Equal(t, "db push", commandName(child)) - assert.Equal(t, "supabase", commandName(root)) -} - -func TestTelemetryIsAgent(t *testing.T) { - t.Run("returns true for agent env", func(t *testing.T) { - clearTelemetryEnv(t) - t.Setenv("CLAUDE_CODE", "1") - utils.AgentMode.Value = "auto" - t.Cleanup(func() { - utils.AgentMode.Value = "auto" - }) - - assert.True(t, telemetryIsAgent()) - }) - - t.Run("returns false with no agent env", func(t *testing.T) { - clearTelemetryEnv(t) - utils.AgentMode.Value = "auto" - t.Cleanup(func() { - utils.AgentMode.Value = "auto" - }) - - assert.False(t, telemetryIsAgent()) - }) -} - -func TestTelemetryEnvSignals(t *testing.T) { - clearTelemetryEnv(t) - t.Setenv("CURSOR_AGENT", "1") - t.Setenv("TERM_PROGRAM", " iTerm.app ") - - signals := telemetryEnvSignals() - - assert.Equal(t, true, signals["CURSOR_AGENT"]) - assert.Equal(t, "iTerm.app", signals["TERM_PROGRAM"]) - assert.NotContains(t, signals, "AI_AGENT") -} - -func TestEnvSignals(t *testing.T) { - clearTelemetryEnv(t) - t.Setenv("AI_AGENT", " ") - t.Setenv("TERM_PROGRAM", " iTerm.app ") - t.Setenv("TERM", strings.Repeat("x", 100)) - - signals := envSignals([]string{"AI_AGENT"}, []string{"TERM_PROGRAM", "TERM"}) - - assert.Equal(t, "iTerm.app", signals["TERM_PROGRAM"]) - assert.Equal(t, strings.Repeat("x", 80), signals["TERM"]) - assert.NotContains(t, signals, "AI_AGENT") -} - -func TestUpdateNotifierEnabled(t *testing.T) { - for value, wantEnabled := range map[string]bool{ - "": true, - "0": true, - "false": true, - "garbage": true, - "1": false, - "true": false, - "TRUE": false, - "t": false, - } { - t.Setenv("SUPABASE_NO_UPDATE_NOTIFIER", value) - assert.Equal(t, wantEnabled, updateNotifierEnabled(), "value %q", value) - } -} - -func TestUpdateNotifierDisabledInStackMode(t *testing.T) { - t.Setenv("SUPABASE_NO_UPDATE_NOTIFIER", "") - t.Setenv(config.StackStateEnv, "1") - assert.False(t, updateNotifierEnabled()) -} diff --git a/apps/cli-go/fsevents/fsevents.go b/apps/cli-go/fsevents/fsevents.go deleted file mode 100644 index d75d319324..0000000000 --- a/apps/cli-go/fsevents/fsevents.go +++ /dev/null @@ -1,117 +0,0 @@ -//go:build darwin - -// Package fsevents provides file system notifications on macOS. -package fsevents - -import ( - "syscall" - "time" -) - -// Event represents a single file system notification. -type Event struct { - // Path holds the path to the item that's changed, relative - // to its device's root. - // Use DeviceForPath to determine the absolute path that's - // being referred to. - Path string - - // Flags holds details what has happened. - Flags EventFlags - - // ID holds the event ID. - // - // Each event ID comes from the most recent event being reported - // in the corresponding directory named in the EventStream.Paths field - // Event IDs all come from a single global source. - // They are guaranteed to always be increasing, usually in leaps - // and bounds, even across system reboots and moving drives from - // one machine to another. If you were to - // stop processing events from this stream after this event - // and resume processing them later from a newly-created - // EventStream, this is the value you would pass for the - // EventStream.EventID along with Resume=true. - ID uint64 -} - -// DeviceForPath returns the device ID for the specified volume. -func DeviceForPath(path string) (int32, error) { - stat := syscall.Stat_t{} - if err := syscall.Lstat(path, &stat); err != nil { - return 0, err - } - return stat.Dev, nil -} - -// EventStream is the primary interface to FSEvents -// You can provide your own event channel if you wish (or one will be -// created on Start). -// -// es := &EventStream{Paths: []string{"/tmp"}, Flags: 0} -// es.Start() -// es.Stop() -// ... -type EventStream struct { - // Events holds the channel on which events will be sent. - // It's initialized by EventStream.Start if nil. - Events chan []Event - - // Paths holds the set of paths to watch, each - // specifying the root of a filesystem hierarchy to be - // watched for modifications. - Paths []string - - // Flags specifies what events to receive on the stream. - Flags CreateFlags - - // Resume specifies that watching should resume from the event - // specified by EventID. - Resume bool - - // EventID holds the most recent event ID. - // - // NOTE: this is updated asynchronously by the - // watcher and should not be accessed while - // the stream has been started. - EventID uint64 - - // Latency holds the number of seconds the service should wait after hearing - // about an event from the kernel before passing it along to the - // client via its callback. Specifying a larger value may result - // in more effective temporal coalescing, resulting in fewer - // callbacks and greater overall efficiency. - Latency time.Duration - - // When Device is non-zero, the watcher will watch events on the - // device with this ID, and the paths in the Paths field are - // interpreted relative to the device's root. - // - // The device ID is the same as the st_dev field from a stat - // structure of a file on that device or the f_fsid[0] field of - // a statfs structure. - Device int32 -} - -// Start listening to an event stream. This creates es.Events if it's not already -// a valid channel. -func (es *EventStream) Start() error { - return nil -} - -// Flush flushes events that have occurred but haven't been delivered. -// If sync is true, it will block until all the events have been delivered, -// otherwise it will return immediately. -func (es *EventStream) Flush(sync bool) { -} - -// Stop stops listening to the event stream. -func (es *EventStream) Stop() { -} - -// Restart restarts the event listener. This -// can be used to change the current watch flags. -func (es *EventStream) Restart() error { - es.Stop() - es.Resume = true - return es.Start() -} diff --git a/apps/cli-go/fsevents/go.mod b/apps/cli-go/fsevents/go.mod deleted file mode 100644 index 41e670c561..0000000000 --- a/apps/cli-go/fsevents/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -go 1.24.10 - -module github.com/fsnotify/fsevents diff --git a/apps/cli-go/fsevents/wrap.go b/apps/cli-go/fsevents/wrap.go deleted file mode 100644 index f12125b7e3..0000000000 --- a/apps/cli-go/fsevents/wrap.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build darwin - -package fsevents - -// CreateFlags specifies what events will be seen in an event stream. -type CreateFlags uint32 - -const ( - // IgnoreSelf doesn't send events triggered by the current process (macOS 10.6+). - // - // Don't send events that were triggered by the current process. - // This is useful for reducing the volume of events that are - // sent. It is only useful if your process might modify the file - // system hierarchy beneath the path(s) being monitored. Note: - // this has no effect on historical events, i.e., those - // delivered before the HistoryDone sentinel event. - IgnoreSelf = CreateFlags(0) - - // FileEvents sends events about individual files, generating significantly - // more events (macOS 10.7+) than directory level notifications. - FileEvents = CreateFlags(1) -) - -// EventFlags passed to the FSEventStreamCallback function. -// These correspond directly to the flags as described here: -// https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags -type EventFlags uint32 - -const ( - // ItemCreated indicates that a file or directory has been created. - ItemCreated = EventFlags(0) - - // ItemIsDir indicates that the item is a directory. - ItemIsDir = EventFlags(1) -) - -// LatestEventID returns the most recently generated event ID, system-wide. -func LatestEventID() uint64 { - return 0 -} diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod deleted file mode 100644 index add818204b..0000000000 --- a/apps/cli-go/go.mod +++ /dev/null @@ -1,379 +0,0 @@ -module github.com/supabase/cli - -go 1.26.0 - -require ( - github.com/BurntSushi/toml v1.6.0 - github.com/Netflix/go-env v0.1.2 - github.com/andybalholm/brotli v1.2.3 - github.com/cenkalti/backoff/v4 v4.3.0 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v1.0.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/compose-spec/compose-go/v2 v2.9.1 - github.com/containerd/errdefs v1.0.0 - github.com/containers/common v0.64.2 - github.com/docker/cli v28.5.2+incompatible - github.com/docker/docker v28.5.2+incompatible - github.com/docker/go-connections v0.8.1 - github.com/docker/go-units v0.5.0 - github.com/getsentry/sentry-go v0.49.0 - github.com/go-errors/errors v1.5.1 - github.com/go-git/go-git/v5 v5.19.2 - github.com/go-playground/validator/v10 v10.30.4 - github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v62 v62.0.0 - github.com/google/go-querystring v1.2.0 - github.com/google/uuid v1.6.0 - github.com/h2non/gock v1.2.0 - github.com/jackc/pgconn v1.14.3 - github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 - github.com/jackc/pgproto3/v2 v2.3.3 - github.com/jackc/pgx/v4 v4.18.3 - github.com/joho/godotenv v1.5.1 - github.com/muesli/reflow v0.3.0 - github.com/oapi-codegen/nullable v1.2.0 - github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.25.1 - github.com/spf13/afero v1.15.0 - github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.10 - github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.1 - github.com/stripe/pg-schema-diff v1.0.9 - github.com/supabase/cli/pkg v1.0.0 - github.com/zalando/go-keyring v0.2.8 - go.opentelemetry.io/otel v1.46.0 - golang.org/x/mod v0.41.0 - golang.org/x/net v0.58.0 - golang.org/x/oauth2 v0.37.0 - golang.org/x/term v0.45.0 - google.golang.org/grpc v1.83.2 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect - 4d63.com/gochecknoglobals v0.2.2 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect - codeberg.org/chavacava/garif v0.2.0 // indirect - codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect - dario.cat/mergo v1.0.2 // indirect - dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect - dev.gaijin.team/go/golib v0.6.0 // indirect - github.com/4meepo/tagalign v1.4.3 // indirect - github.com/Abirdcfly/dupword v0.1.7 // indirect - github.com/AdminBenni/iota-mixing v1.0.0 // indirect - github.com/AlwxSin/noinlineerr v1.0.5 // indirect - github.com/Antonboom/errname v1.1.1 // indirect - github.com/Antonboom/nilnil v1.1.1 // indirect - github.com/Antonboom/testifylint v1.6.4 // indirect - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect - github.com/Djarvur/go-err113 v0.1.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/MirrexOne/unqueryvet v1.5.4 // indirect - github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/alecthomas/chroma/v2 v2.24.1 // indirect - github.com/alecthomas/go-check-sumtype v0.3.1 // indirect - github.com/alexkohler/nakedret/v2 v2.0.6 // indirect - github.com/alexkohler/prealloc v1.1.0 // indirect - github.com/alfatraining/structtag v1.0.0 // indirect - github.com/alingse/asasalint v0.0.11 // indirect - github.com/alingse/nilnesserr v0.2.0 // indirect - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect - github.com/ashanbrown/makezero/v2 v2.2.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bitfield/gotestdox v0.2.2 // indirect - github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blizzy78/varnamelen v0.8.0 // indirect - github.com/bombsimon/wsl/v4 v4.7.0 // indirect - github.com/bombsimon/wsl/v5 v5.8.0 // indirect - github.com/breml/bidichk v0.3.3 // indirect - github.com/breml/errchkjson v0.4.1 // indirect - github.com/butuzov/ireturn v0.4.1 // indirect - github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.10.1 // indirect - github.com/ccojocar/zxcvbn-go v1.0.4 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.11 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/ckaznocha/intrange v0.3.1 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containers/storage v1.59.1 // indirect - github.com/creack/pty v1.1.24 // indirect - github.com/curioswitch/go-reassign v0.3.0 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/daixiang0/gci v0.13.7 // indirect - github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dave/dst v0.27.3 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/dnephin/pflag v1.0.7 // indirect - github.com/docker/docker-credential-helpers v0.9.5 // indirect - github.com/docker/go-metrics v0.0.1 // indirect - github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect - github.com/ecies/go/v2 v2.0.11 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/ethereum/go-ethereum v1.17.0 // indirect - github.com/ettle/strcase v0.2.0 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/fatih/structtag v1.2.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/fvbommel/sortorder v1.1.0 // indirect - github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.15 // indirect - github.com/getkin/kin-openapi v0.144.0 // indirect - github.com/ghostiam/protogetter v0.3.20 // indirect - github.com/go-critic/go-critic v0.14.3 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-toolsmith/astcast v1.1.0 // indirect - github.com/go-toolsmith/astcopy v1.1.0 // indirect - github.com/go-toolsmith/astequal v1.2.0 // indirect - github.com/go-toolsmith/astfmt v1.1.0 // indirect - github.com/go-toolsmith/astp v1.1.0 // indirect - github.com/go-toolsmith/strparse v1.1.0 // indirect - github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.6 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/godoc-lint/godoc-lint v0.11.2 // indirect - github.com/gofrs/flock v0.13.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golangci/asciicheck v0.5.0 // indirect - github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect - github.com/golangci/go-printf-func-name v0.1.1 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint/v2 v2.12.2 // indirect - github.com/golangci/golines v0.15.0 // indirect - github.com/golangci/misspell v0.8.0 // indirect - github.com/golangci/plugin-module-register v0.1.2 // indirect - github.com/golangci/revgrep v0.8.0 // indirect - github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect - github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect - github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/gordonklaus/ineffassign v0.2.0 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/gostaticanalysis/analysisutil v0.7.1 // indirect - github.com/gostaticanalysis/comment v1.5.0 // indirect - github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect - github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect - github.com/hashicorp/go-version v1.9.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgio v1.0.0 // indirect - github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgtype v1.14.4 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jgautheron/goconst v1.10.0 // indirect - github.com/jjti/go-spancheck v0.6.5 // indirect - github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/kisielk/errcheck v1.10.0 // indirect - github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/klauspost/compress v1.18.5 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/kulti/thelper v0.7.1 // indirect - github.com/kunwardeep/paralleltest v1.0.15 // indirect - github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.5 // indirect - github.com/ldez/gomoddirectives v0.8.0 // indirect - github.com/ldez/grignotin v0.10.1 // indirect - github.com/ldez/structtags v0.6.1 // indirect - github.com/ldez/tagliatelle v0.7.2 // indirect - github.com/ldez/usetesting v0.5.0 // indirect - github.com/leodido/go-urn v1.5.0 // indirect - github.com/leonklingele/grouper v1.1.2 // indirect - github.com/lib/pq v1.12.3 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/macabu/inamedparam v0.2.0 // indirect - github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect - github.com/manuelarte/funcorder v0.6.0 // indirect - github.com/maratori/testableexamples v1.0.1 // indirect - github.com/maratori/testpackage v1.1.2 // indirect - github.com/matoous/godox v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/mattn/go-shellwords v1.0.12 // indirect - github.com/mgechev/revive v1.15.0 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/term v0.5.2 // indirect - github.com/moricho/tparallel v0.3.2 // indirect - github.com/morikuni/aec v1.1.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nakabonne/nestif v0.3.1 // indirect - github.com/nishanths/exhaustive v0.12.0 // indirect - github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.23.0 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 // indirect - github.com/oapi-codegen/runtime v1.7.0 // indirect - github.com/oasdiff/yaml v0.1.1 // indirect - github.com/oasdiff/yaml3 v0.0.14 // indirect - github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.2.0 // indirect - github.com/olekukonko/ll v0.1.6 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/otiai10/copy v1.14.1 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect - github.com/prometheus/procfs v0.19.2 // indirect - github.com/quasilyte/go-ruleguard v0.4.5 // indirect - github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect - github.com/quasilyte/gogrep v0.5.0 // indirect - github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect - github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect - github.com/raeperd/recvcheck v0.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/ryancurrah/gomodguard v1.4.1 // indirect - github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect - github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect - github.com/securego/gosec/v2 v2.26.1 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - github.com/sivchari/containedctx v1.0.3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect - github.com/sonatard/noctx v0.5.1 // indirect - github.com/sourcegraph/go-diff v0.8.0 // indirect - github.com/speakeasy-api/jsonpath v0.6.3 // indirect - github.com/speakeasy-api/openapi v1.19.2 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect - github.com/stretchr/objx v0.5.3 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/tetafro/godot v1.5.6 // indirect - github.com/theupdateframework/notary v0.7.0 // indirect - github.com/tidwall/jsonc v0.3.3 // indirect - github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect - github.com/timonwong/loggercheck v0.11.0 // indirect - github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect - github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect - github.com/ultraware/funlen v0.2.0 // indirect - github.com/ultraware/whitespace v0.2.0 // indirect - github.com/uudashr/gocognit v1.2.1 // indirect - github.com/uudashr/iface v1.4.2 // indirect - github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/xen0n/gosmopolitan v1.3.0 // indirect - github.com/xhit/go-str2duration/v2 v2.1.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yagipy/maintidx v1.0.0 // indirect - github.com/yeya24/promlinter v0.3.0 // indirect - github.com/ykadowak/zerologlint v0.1.5 // indirect - github.com/yuin/goldmark v1.7.13 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect - gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.14.0 // indirect - go-simpler.org/sloglint v0.12.0 // indirect - go.augendre.info/arangolint v0.4.0 // indirect - go.augendre.info/fatcontext v0.9.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.46.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.49.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect - gotest.tools/gotestsum v1.12.2 // indirect - honnef.co/go/tools v0.7.0 // indirect - mvdan.cc/gofumpt v0.9.2 // indirect - mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect -) - -replace github.com/supabase/cli/pkg v1.0.0 => ./pkg - -replace github.com/fsnotify/fsevents v0.2.0 => ./fsevents - -tool ( - github.com/golangci/golangci-lint/v2/cmd/golangci-lint - github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen - gotest.tools/gotestsum -) diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum deleted file mode 100644 index 9cf1bac004..0000000000 --- a/apps/cli-go/go.sum +++ /dev/null @@ -1,1232 +0,0 @@ -4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= -4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= -4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= -4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= -codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= -codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= -codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= -codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= -dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= -dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= -github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= -github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= -github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= -github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= -github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= -github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= -github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= -github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= -github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= -github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= -github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= -github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= -github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= -github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= -github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= -github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= -github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= -github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= -github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= -github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= -github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= -github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= -github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= -github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= -github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= -github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= -github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= -github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= -github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= -github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= -github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= -github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ= -github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= -github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= -github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= -github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= -github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= -github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= -github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= -github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= -github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= -github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= -github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= -github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= -github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= -github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= -github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= -github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= -github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= -github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= -github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= -github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= -github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= -github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= -github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= -github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= -github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= -github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= -github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= -github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= -github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= -github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/compose-spec/compose-go/v2 v2.9.1 h1:8UwI+ujNU+9Ffkf/YgAm/qM9/eU7Jn8nHzWG721W4rs= -github.com/compose-spec/compose-go/v2 v2.9.1/go.mod h1:Oky9AZGTRB4E+0VbTPZTUu4Kp+oEMMuwZXZtPPVT1iE= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containers/common v0.64.2 h1:1xepE7QwQggUXxmyQ1Dbh6Cn0yd7ktk14sN3McSWf5I= -github.com/containers/common v0.64.2/go.mod h1:o29GfYy4tefUuShm8mOn2AiL5Mpzdio+viHI7n24KJ4= -github.com/containers/storage v1.59.1 h1:11Zu68MXsEQGBBd+GadPrHPpWeqjKS8hJDGiAHgIqDs= -github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= -github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= -github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= -github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= -github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= -github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= -github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= -github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= -github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= -github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= -github.com/docker/cli v28.5.2+incompatible h1:XmG99IHcBmIAoC1PPg9eLBZPlTrNUAijsHLm8PjhBlg= -github.com/docker/cli v28.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= -github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= -github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0= -github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= -github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= -github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= -github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= -github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= -github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= -github.com/ecies/go/v2 v2.0.11 h1:xYhtMdLiqNi02oLirFmLyNbVXw6250h3WM6zJryQdiM= -github.com/ecies/go/v2 v2.0.11/go.mod h1:LPRzoefP0Tam+1uesQOq3Gtb6M2OwlFUnXBTtBAKfDQ= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= -github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= -github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= -github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= -github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= -github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= -github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= -github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= -github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= -github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= -github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= -github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= -github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= -github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= -github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= -github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= -github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= -github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= -github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= -github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= -github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= -github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= -github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= -github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= -github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= -github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= -github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= -github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= -github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= -github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= -github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= -github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= -github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= -github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= -github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= -github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= -github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= -github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= -github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= -github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= -github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= -github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= -github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= -github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs= -github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8= -github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= -github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= -github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= -github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= -github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= -github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= -github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= -github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= -github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= -github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= -github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= -github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= -github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= -github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= -github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= -github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= -github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= -github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= -github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= -github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= -github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= -github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= -github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= -github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= -github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= -github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= -github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= -github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= -github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= -github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= -github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= -github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= -github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= -github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= -github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= -github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= -github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= -github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= -github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= -github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= -github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= -github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= -github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= -github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= -github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= -github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= -github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= -github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= -github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= -github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= -github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= -github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= -github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= -github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= -github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= -github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= -github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= -github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= -github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= -github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= -github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= -github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= -github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= -github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= -github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= -github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= -github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= -github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= -github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= -github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= -github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= -github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= -github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= -github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= -github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= -github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= -github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= -github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= -github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= -github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= -github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= -github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/nullable v1.2.0 h1:VflFkDW980KhBPiFF7nWSyjg+r4Obqj8lXipV0UkP5w= -github.com/oapi-codegen/nullable v1.2.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 h1:a7Ab7YlpqkVG5HKrTaeFstm32Z5QOnyjnbsCO0jiMYM= -github.com/oapi-codegen/oapi-codegen/v2 v2.7.1/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= -github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= -github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= -github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= -github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= -github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= -github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= -github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= -github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= -github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= -github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= -github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= -github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= -github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= -github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= -github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= -github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= -github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= -github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= -github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.25.1 h1:jo5cW5GGoY41noy33Jn6A2N9Z5yrIGKHBr4DjAvnCrc= -github.com/posthog/posthog-go v1.25.1/go.mod h1:rN0C9Urenhytgo3KQ/QVorGkyGfpouquDTSs2Ujzr+g= -github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= -github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= -github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= -github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= -github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= -github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= -github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= -github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= -github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= -github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA= -github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU= -github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= -github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= -github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= -github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= -github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= -github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= -github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= -github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= -github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= -github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= -github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= -github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= -github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= -github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= -github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= -github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/stripe/pg-schema-diff v1.0.9 h1:oI1MPWAEuvuc/TCeGoJJ6sc+70Rs+epFM3LXvTFZxYE= -github.com/stripe/pg-schema-diff v1.0.9/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= -github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= -github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= -github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c= -github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw= -github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= -github.com/tidwall/jsonc v0.3.3/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= -github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= -github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= -github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= -github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= -github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= -github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= -github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= -github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= -github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= -github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= -github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= -github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg= -github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= -github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= -github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= -github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= -github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= -github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= -github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= -github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= -github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= -github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= -github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= -github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= -github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= -gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= -go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= -go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= -go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= -go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= -go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= -go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= -go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= -go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= -go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= -go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= -golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1/go.mod h1:WbjuEoo1oadwzQ4apSDU+JTvmllEHtsNHS6y7vFc7iw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/gotestsum v1.12.2 h1:eli4tu9Q2D/ogDsEGSr8XfQfl7mT0JsGOG6DFtUiZ/Q= -gotest.tools/gotestsum v1.12.2/go.mod h1:kjRtCglPZVsSU0hFHX3M5VWBM6Y63emHuB14ER1/sow= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= -mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go deleted file mode 100644 index f4c05fb801..0000000000 --- a/apps/cli-go/internal/db/diff/diff.go +++ /dev/null @@ -1,254 +0,0 @@ -package diff - -import ( - "context" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/go-connections/nat" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/migration/new" - "github.com/supabase/cli/internal/utils" - configpkg "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/parser" -) - -type DiffFunc func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error) - -// DatabaseDiff is the result of diffing a target database against a shadow baseline. -type DatabaseDiff struct { - SQL string -} - -func Run(ctx context.Context, schema []string, file string, config pgconn.Config, differ DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (err error) { - result, err := DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, options...) - if err != nil { - return err - } - out := result.SQL - branch := utils.GetGitBranch(fsys) - fmt.Fprintln(os.Stderr, "Finished "+utils.Aqua("supabase db diff")+" on branch "+utils.Aqua(branch)+".\n") - if err := SaveDiff(result, file, fsys); err != nil { - return err - } - drops := findDropStatements(out) - if len(drops) > 0 { - fmt.Fprintln(os.Stderr, "Found drop statements in schema diff. Please double check if these are expected:") - fmt.Fprintln(os.Stderr, utils.Yellow(strings.Join(drops, "\n"))) - } - return nil -} - -func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { - if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { - return schemas.SQLFiles( - afero.NewIOFS(fsys), - configpkg.WithSkipEmptyGlobs(), - configpkg.WithErrorOnAllSkippedGlobs(), - ) - } - // When pg-delta is enabled, declarative path is the source of truth (config or default). - if utils.IsPgDeltaEnabled() { - declDir := utils.GetDeclarativeDir() - if exists, err := afero.DirExists(fsys, declDir); err == nil && exists { - var declared []string - if err := afero.Walk(fsys, declDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() && filepath.Ext(info.Name()) == ".sql" { - declared = append(declared, path) - } - return nil - }); err != nil { - return nil, errors.Errorf("failed to walk declarative dir: %w", err) - } - sort.Strings(declared) - return declared, nil - } - } - if exists, err := afero.DirExists(fsys, utils.SchemasDir); err != nil { - return nil, errors.Errorf("failed to check schemas: %w", err) - } else if !exists { - return nil, nil - } - var declared []string - if err := afero.Walk(fsys, utils.SchemasDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() && filepath.Ext(info.Name()) == ".sql" { - declared = append(declared, path) - } - return nil - }); err != nil { - return nil, errors.Errorf("failed to walk dir: %w", err) - } - // Keep file application order deterministic so diff output stays stable across - // filesystems and operating systems. This is only if no schema paths in config are set. - sort.Strings(declared) - return declared, nil -} - -var warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. -Run ` + utils.Aqua("supabase db reset") + ` to verify that the new migration does not generate errors.` - -func SaveDiff(result DatabaseDiff, file string, fsys afero.Fs) error { - out := result.SQL - if len(out) < 2 { - fmt.Fprintln(os.Stderr, "No schema changes found") - } else if len(file) > 0 { - path := new.GetMigrationPath(utils.GetCurrentTimestamp(), file) - if err := utils.WriteFile(path, []byte(out), fsys); err != nil { - return err - } - fmt.Fprintln(os.Stderr, warnDiff) - } else { - fmt.Println(out) - } - return nil -} - -// https://github.com/djrobstep/migra/blob/master/migra/statements.py#L6 -var dropStatementPattern = regexp.MustCompile(`(?i)drop\s+`) - -func findDropStatements(out string) []string { - lines, err := parser.SplitAndTrim(strings.NewReader(out)) - if err != nil { - return nil - } - var drops []string - for _, line := range lines { - if dropStatementPattern.MatchString(line) { - drops = append(drops, line) - } - } - return drops -} - -func CreateShadowDatabase(ctx context.Context, port uint16) (string, error) { - // Disable background workers in shadow database - config := start.NewContainerConfig("-c", "max_worker_processes=0") - hostPort := strconv.FormatUint(uint64(port), 10) - hostConfig := container.HostConfig{ - PortBindings: nat.PortMap{"5432/tcp": []nat.PortBinding{{HostPort: hostPort}}}, - AutoRemove: true, - } - networkingConfig := network.NetworkingConfig{} - if utils.Config.Db.MajorVersion <= 14 { - hostConfig.Tmpfs = map[string]string{"/docker-entrypoint-initdb.d": ""} - } - return utils.DockerStart(ctx, config, hostConfig, networkingConfig, "") -} - -func ConnectShadowDatabase(ctx context.Context, timeout time.Duration, options ...func(*pgx.ConnConfig)) (conn *pgx.Conn, err error) { - // Retry until connected, cancelled, or timeout - policy := start.NewBackoffPolicy(ctx, timeout) - config := pgconn.Config{Port: utils.Config.Db.ShadowPort} - connect := func() (*pgx.Conn, error) { - return utils.ConnectLocalPostgres(ctx, config, options...) - } - return backoff.RetryWithData(connect, policy) -} - -// Required to bypass pg_cron check: https://github.com/citusdata/pg_cron/blob/main/pg_cron.sql#L3 -const CREATE_TEMPLATE = "CREATE DATABASE contrib_regression TEMPLATE postgres" - -// setupShadowConn applies the Supabase platform schema (auth, storage, realtime, -// etc.) to an already-connected shadow database and creates the pg_cron template -// database. It deliberately stops short of applying user migrations so that -// callers which only need the platform baseline (declarative apply) share the -// exact same starting point as callers that also replay migrations. -func setupShadowConn(ctx context.Context, conn *pgx.Conn, container string, fsys afero.Fs) error { - if err := start.SetupDatabase(ctx, conn, container[:12], os.Stderr, fsys); err != nil { - return err - } - if _, err := conn.Exec(ctx, CREATE_TEMPLATE); err != nil { - return errors.Errorf("failed to create template database: %w", err) - } - return nil -} - -// SetupShadowDatabase provisions the Supabase platform baseline (service schemas -// such as auth/storage/realtime) on a freshly created shadow database, without -// applying user migrations. Declarative apply uses this so the shadow matches the -// real database closely enough for Supabase-managed dependencies (auth.sessions, -// auth.jwt(), ...) to resolve. -func SetupShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - conn, err := ConnectShadowDatabase(ctx, 10*time.Second, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - return setupShadowConn(ctx, conn, container, fsys) -} - -func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) - if err != nil { - return err - } - conn, err := ConnectShadowDatabase(ctx, 10*time.Second, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if err := setupShadowConn(ctx, conn, container, fsys); err != nil { - return err - } - return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) -} - -func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, options ...func(*pgx.ConnConfig)) (DatabaseDiff, error) { - fmt.Fprintln(w, "Creating shadow database...") - shadowSource, err := PrepareShadowSource(ctx, utils.IsLocalDatabase(config), fsys, options...) - if err != nil { - return DatabaseDiff{}, err - } - defer utils.DockerRemove(shadowSource.Container) - shadowConfig := shadowSource.Source - if shadowSource.TargetOverride != nil { - config = *shadowSource.TargetOverride - } - if len(schema) > 0 { - fmt.Fprintln(w, "Diffing schemas:", strings.Join(schema, ",")) - } else { - fmt.Fprintln(w, "Diffing schemas...") - } - output, err := differ(ctx, shadowConfig, config, schema, options...) - if err != nil { - return DatabaseDiff{}, err - } - return DatabaseDiff{SQL: output}, nil -} - -func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations []string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - fmt.Fprintln(os.Stderr, "Creating local database from declarative schemas:") - msg := make([]string, len(migrations)) - for i, m := range migrations { - msg[i] = fmt.Sprintf(" • %s", utils.Bold(m)) - } - fmt.Fprintln(os.Stderr, strings.Join(msg, "\n")) - conn, err := utils.ConnectLocalPostgres(ctx, config, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - return migration.SeedGlobals(ctx, migrations, conn, afero.NewIOFS(fsys)) -} diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go deleted file mode 100644 index af0cacf610..0000000000 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ /dev/null @@ -1,516 +0,0 @@ -package diff - -import ( - "context" - "errors" - "io" - "net/http" - "os" - "path/filepath" - "testing" - "time" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - pkgconfig "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: "db.supabase.co", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestLoadDeclaredSchemas(t *testing.T) { - t.Run("respects schema_paths order when pg-delta declarative dir exists", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ - "supabase/schemas/z_function.sql", - "supabase/schemas/a_table.sql", - } - utils.Config.Experimental.PgDelta = &pkgconfig.PgDeltaConfig{ - Enabled: true, - DeclarativeSchemaPath: utils.SchemasDir, - } - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(utils.SchemasDir, 0755)) - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/a_table.sql", []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/z_function.sql", []byte("create function z() returns void language sql as $$ select 1 $$;"), 0644)) - - declared, err := loadDeclaredSchemas(fsys) - - require.NoError(t, err) - assert.Equal(t, []string{ - "supabase/schemas/z_function.sql", - "supabase/schemas/a_table.sql", - }, declared) - }) - - t.Run("expands schema_paths directory entries deterministically", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{utils.DeclarativeDir} - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.DeclarativeDir, "nested"), 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), []byte("select 2;"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "a.sql"), []byte("select 1;"), 0644)) - - declared, err := loadDeclaredSchemas(fsys) - - require.NoError(t, err) - assert.Equal(t, []string{ - filepath.Join(utils.DeclarativeDir, "a.sql"), - filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), - }, declared) - }) -} - -func TestRun(t *testing.T) { - t.Run("runs migra diff", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, flags.LoadConfig(fsys)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Realtime.Image), "test-shadow-realtime") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-shadow-realtime", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Storage.Image), "test-shadow-storage") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-shadow-storage", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Auth.Image), "test-shadow-auth") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-shadow-auth", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), "test-migra") - diff := "create table test();" - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-migra", diff)) - // Setup mock postgres: with auto_expose_new_tables unset, the shadow database setup - // revokes the default Data API GRANTs before creating the regression template. - conn := pgtest.NewConn() - helper.MockApiPrivilegesRevoke(conn). - Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") - defer conn.Close(t) - // Run test - err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, fsys, func(cc *pgx.ConnConfig) { - if cc.Host == dbConfig.Host { - // Fake a SSL error when connecting to target database - cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) { - return nil, errors.New("server refused TLS connection") - } - } else { - // Hijack connection to shadow database - conn.Intercept(cc) - } - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // Check diff file - files, err := afero.ReadDir(fsys, utils.MigrationsDir) - assert.NoError(t, err) - assert.Equal(t, 1, len(files)) - diffPath := filepath.Join(utils.MigrationsDir, files[0].Name()) - contents, err := afero.ReadFile(fsys, diffPath) - assert.NoError(t, err) - assert.Equal(t, []byte(diff), contents) - }) - - t.Run("throws error on failure to diff target", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errors.New("network error")) - // Run test - err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, fsys) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestMigrateShadow(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - - t.Run("migrates shadow database", func(t *testing.T) { - utils.Config.Db.ShadowPort = 54320 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - sql := "create schema test" - require.NoError(t, afero.WriteFile(fsys, path, []byte(sql), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(sql). - Reply("CREATE SCHEMA"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") - // Run test - err := MigrateShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on timeout", func(t *testing.T) { - utils.Config.Db.ShadowPort = 54320 - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - // Run test - err := MigrateShadowDatabase(ctx, "", fsys) - // Check error - assert.ErrorIs(t, err, context.Canceled) - }) - - t.Run("throws error on permission denied", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Run test - err := MigrateShadowDatabase(context.Background(), "", fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) - - t.Run("throws error on globals schema", func(t *testing.T) { - utils.Config.Db.ShadowPort = 54320 - utils.GlobalsSql = "create schema public" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) - // Run test - err := MigrateShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06)`) - }) -} - -func TestSetupShadowDatabase(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - - t.Run("sets up platform baseline without applying migrations", func(t *testing.T) { - utils.Config.Db.ShadowPort = 54320 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - // A migration exists on disk, but SetupShadowDatabase must not apply it: - // the mock below only scripts the platform setup + template, so any - // migration-history query would surface as an unmatched request. - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte("create schema test"), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") - // Run test - err := SetupShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on globals schema", func(t *testing.T) { - utils.Config.Db.ShadowPort = 54320 - utils.GlobalsSql = "create schema public" - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) - // Run test - err := SetupShadowDatabase(context.Background(), "test-shadow-db", afero.NewMemMapFs(), conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06)`) - }) -} - -func TestDiffDatabase(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - utils.Config.Db.ShadowPort = 54320 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - - t.Run("throws error on failure to create shadow", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errNetwork) - // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra) - // Check error - assert.Empty(t, result) - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on health check failure", func(t *testing.T) { - utils.Config.Db.HealthTimeout = time.Millisecond - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: false, - Status: "exited", - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/logs"). - Reply(http.StatusServiceUnavailable) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). - Reply(http.StatusOK) - // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra) - // Check error - assert.Empty(t, result) - assert.ErrorContains(t, err, "test-shadow-db container is not running: exited") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to migrate shadow", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). - Reply(http.StatusOK) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) - // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, conn.Intercept) - // Check error - assert.Empty(t, result) - assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06) -At statement: 0 -create schema public`) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to diff target", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - sql := "create schema test" - require.NoError(t, afero.WriteFile(fsys, path, []byte(sql), 0644)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). - Reply(http.StatusOK) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), "test-migra") - // The edge-runtime diff waits for the container to exit via inspect before - // reading its logs (it must not follow the log stream — that hangs under - // podman, supabase/pg-toolbelt#312), so the diff failure here surfaces from - // the log read rather than the followed stream. - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ExitCode: 0}, - }}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/logs"). - ReplyError(errors.New("network error")) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-migra"). - Reply(http.StatusOK) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(sql). - Reply("CREATE SCHEMA"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") - // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, func(cc *pgx.ConnConfig) { - if cc.Host == dbConfig.Host { - // Fake a SSL error when connecting to target database - cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) { - return nil, errors.New("server refused TLS connection") - } - } else { - // Hijack connection to shadow database - conn.Intercept(cc) - } - }) - // Check error - assert.Empty(t, result) - assert.ErrorContains(t, err, "error diffing schema") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestDropStatements(t *testing.T) { - drops := findDropStatements("create table t(); drop table t; alter table t drop column c") - assert.Equal(t, []string{"drop table t", "alter table t drop column c"}, drops) -} - -func TestLoadSchemas(t *testing.T) { - expected := []string{ - filepath.Join(utils.SchemasDir, "comment", "model.sql"), - filepath.Join(utils.SchemasDir, "model.sql"), - filepath.Join(utils.SchemasDir, "reaction", "dislike", "model.sql"), - filepath.Join(utils.SchemasDir, "reaction", "like", "model.sql"), - } - fsys := afero.NewMemMapFs() - for _, fp := range expected { - require.NoError(t, afero.WriteFile(fsys, fp, nil, 0644)) - } - // Run test - schemas, err := loadDeclaredSchemas(fsys) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, expected, schemas) -} - -func TestLoadSchemasSkipsEmptySchemaPathGlobs(t *testing.T) { - fsys := afero.NewMemMapFs() - matched := filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql") - require.NoError(t, afero.WriteFile(fsys, matched, nil, 0644)) - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), - filepath.Join(utils.SupabaseDirPath, "schemas", "materialized_views", "*.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.NoError(t, err) - assert.Equal(t, []string{filepath.ToSlash(matched)}, schemas) -} - -func TestLoadSchemasErrorsOnMissingLiteralSchemaPath(t *testing.T) { - fsys := afero.NewMemMapFs() - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.ErrorContains(t, err, "no files matched pattern") - assert.Empty(t, schemas) -} - -func TestLoadSchemasErrorsWhenAllSchemaPathGlobsAreEmpty(t *testing.T) { - fsys := afero.NewMemMapFs() - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), - filepath.Join(utils.SupabaseDirPath, "schemas", "views", "*.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.ErrorContains(t, err, "no files matched pattern") - assert.Empty(t, schemas) -} diff --git a/apps/cli-go/internal/db/diff/migra.go b/apps/cli-go/internal/db/diff/migra.go deleted file mode 100644 index b5c001825b..0000000000 --- a/apps/cli-go/internal/db/diff/migra.go +++ /dev/null @@ -1,162 +0,0 @@ -package diff - -import ( - "bytes" - "context" - _ "embed" - "strings" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/internal/gen/types" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/migration" -) - -var ( - //go:embed templates/migra.sh - diffSchemaScript string - //go:embed templates/migra.ts - diffSchemaTypeScript string - - managedSchemas = []string{ - // Local development - "_analytics", - "_realtime", - "_supavisor", - // Owned by extensions - "cron", - "graphql", - "graphql_public", - "net", - "pgroonga", - "pgtle", - "repack", - "tiger_data", - "vault", - // Deprecated extensions - "pgsodium", - "pgsodium_masks", - "timescaledb_experimental", - "timescaledb_information", - "_timescaledb_cache", - "_timescaledb_catalog", - "_timescaledb_config", - "_timescaledb_debug", - "_timescaledb_functions", - "_timescaledb_internal", - // Managed by Supabase - "pgbouncer", - "supabase_functions", - "supabase_migrations", - } -) - -// Diffs local database schema against shadow, dumps output to stdout. -func DiffSchemaMigraBash(ctx context.Context, source, target pgconn.Config, schema []string, options ...func(*pgx.ConnConfig)) (string, error) { - // Load all user defined schemas - if len(schema) == 0 { - var err error - if schema, err = loadSchema(ctx, target, options...); err != nil { - return "", err - } - } - env := []string{ - "SOURCE=" + utils.ToPostgresURL(source), - "TARGET=" + utils.ToPostgresURL(target), - } - if types.IsSSLDebugEnabled() { - env = append(env, "SUPABASE_SSL_DEBUG=true") - } - // Passing in script string means command line args must be set manually, ie. "$@" - args := "set -- " + strings.Join(schema, " ") + ";" - cmd := []string{"/bin/sh", "-c", args + diffSchemaScript} - var out, stderr bytes.Buffer - if err := utils.DockerRunOnceWithConfig( - ctx, - container.Config{ - Image: config.Images.Migra, - Env: env, - Cmd: cmd, - }, - container.HostConfig{ - NetworkMode: network.NetworkHost, - }, - network.NetworkingConfig{}, - "", - &out, - &stderr, - ); err != nil { - return "", errors.Errorf("error diffing schema: %w:\n%s", err, stderr.String()) - } - return out.String(), nil -} - -func loadSchema(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) ([]string, error) { - conn, err := utils.ConnectByConfig(ctx, config, options...) - if err != nil { - return nil, err - } - defer conn.Close(context.Background()) - // RLS policies in auth and storage schemas can be included with -s flag - return migration.ListUserSchemas(ctx, conn) -} - -func DiffSchemaMigra(ctx context.Context, source, target pgconn.Config, schema []string, options ...func(*pgx.ConnConfig)) (string, error) { - env := []string{ - "SOURCE=" + utils.ToPostgresURL(source), - "TARGET=" + utils.ToPostgresURL(target), - } - debugf := func(string, ...any) {} - if types.IsSSLDebugEnabled() { - debugf = types.LogSSLDebugf - env = append(env, "SUPABASE_SSL_DEBUG=true") - debugf("DiffSchemaMigra source_host=%s source_port=%d target_host=%s target_port=%d target_db=%s", - source.Host, - source.Port, - target.Host, - target.Port, - target.Database, - ) - debugf("DiffSchemaMigra docker_daemon=%s image=%s", utils.Docker.DaemonHost(), utils.Config.EdgeRuntime.Image) - } - if ca, err := types.GetRootCA(ctx, utils.ToPostgresURL(target), options...); err != nil { - debugf("DiffSchemaMigra GetRootCA error=%v", err) - return "", err - } else if len(ca) > 0 { - env = append(env, "SSL_CA="+ca) - debugf("DiffSchemaMigra GetRootCA ca_bundle_len=%d", len(ca)) - } - if len(schema) > 0 { - env = append(env, "INCLUDED_SCHEMAS="+strings.Join(schema, ",")) - } else { - env = append(env, "EXCLUDED_SCHEMAS="+strings.Join(managedSchemas, ",")) - } - // Migra also executes via Edge Runtime because the TypeScript implementation - // shares the same containerized execution environment as other diff engines. - // The helper remains in package diff to avoid coupling migra code paths to - // pg-delta-specific packages. - binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} - var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, diffSchemaTypeScript, binds, "error diffing schema", &stdout, &stderr); err != nil { - if shouldFallbackToLegacyMigra(err) { - debugf("DiffSchemaMigra falling back to legacy migra after edge-runtime OOM") - return DiffSchemaMigraBash(ctx, source, target, schema, options...) - } - return "", err - } - return stdout.String(), nil -} - -func shouldFallbackToLegacyMigra(err error) bool { - if err == nil { - return false - } - message := err.Error() - return strings.Contains(message, "Fatal JavaScript out of memory") || - strings.Contains(message, "Ineffective mark-compacts near heap limit") -} diff --git a/apps/cli-go/internal/db/diff/pgschema.go b/apps/cli-go/internal/db/diff/pgschema.go deleted file mode 100644 index 5126ca96ed..0000000000 --- a/apps/cli-go/internal/db/diff/pgschema.go +++ /dev/null @@ -1,58 +0,0 @@ -package diff - -import ( - "context" - "database/sql" - "fmt" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - pgschema "github.com/stripe/pg-schema-diff/pkg/diff" - "github.com/supabase/cli/internal/utils" -) - -func DiffPgSchema(ctx context.Context, source, target pgconn.Config, schema []string, _ ...func(*pgx.ConnConfig)) (string, error) { - dbSrc, err := sql.Open("pgx", utils.ToPostgresURL(source)) - if err != nil { - return "", errors.Errorf("failed to open source database: %w", err) - } - defer dbSrc.Close() - dbDst, err := sql.Open("pgx", utils.ToPostgresURL(target)) - if err != nil { - return "", errors.Errorf("failed to open target database: %w", err) - } - defer dbDst.Close() - // Generate DDL based on schema plan - opts := []pgschema.PlanOpt{pgschema.WithDoNotValidatePlan()} - if len(schema) > 0 { - opts = append(opts, pgschema.WithIncludeSchemas(schema...)) - } else { - opts = append(opts, - pgschema.WithExcludeSchemas(managedSchemas...), - pgschema.WithExcludeSchemas( - "topology", // unsupported due to views - "realtime", // unsupported due to partitioned table - "storage", // unsupported due to unique index - ), - ) - } - plan, err := pgschema.Generate( - ctx, - pgschema.DBSchemaSource(dbSrc), - pgschema.DBSchemaSource(dbDst), - opts..., - ) - if err != nil { - return "", errors.Errorf("failed to generate plan: %w", err) - } - var lines []string - for _, stat := range plan.Statements { - for _, harzard := range stat.Hazards { - lines = append(lines, fmt.Sprintf("-- %s", harzard)) - } - lines = append(lines, fmt.Sprintf("%s;\n", stat.DDL)) - } - return fmt.Sprintln(strings.Join(lines, "\n")), nil -} diff --git a/apps/cli-go/internal/db/diff/save_diff_test.go b/apps/cli-go/internal/db/diff/save_diff_test.go deleted file mode 100644 index e03df73eec..0000000000 --- a/apps/cli-go/internal/db/diff/save_diff_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package diff - -import ( - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestSaveDiff(t *testing.T) { - t.Run("reports no changes on empty diff", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: ""}, "my_diff", fsys)) - entries, err := afero.ReadDir(fsys, utils.MigrationsDir) - assert.Error(t, err) - assert.Empty(t, entries) - }) - - t.Run("writes a single migration file", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "my_diff", fsys)) - entries, err := afero.ReadDir(fsys, utils.MigrationsDir) - require.NoError(t, err) - require.Len(t, entries, 1) - assert.Regexp(t, `^\d{14}_my_diff\.sql$`, entries[0].Name()) - contents, err := afero.ReadFile(fsys, utils.MigrationsDir+"/"+entries[0].Name()) - require.NoError(t, err) - assert.Equal(t, "create table a ();", string(contents)) - }) - - t.Run("prints diff to stdout when no file is given", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "", fsys)) - entries, _ := afero.ReadDir(fsys, utils.MigrationsDir) - assert.Empty(t, entries) - }) - - t.Run("creates nested parent directories for a nested name", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "snapshots/remote", fsys)) - matches, err := afero.Glob(fsys, utils.MigrationsDir+"/*_snapshots/remote.sql") - require.NoError(t, err) - require.Len(t, matches, 1) - contents, err := afero.ReadFile(fsys, matches[0]) - require.NoError(t, err) - assert.Equal(t, "create table a ();", string(contents)) - }) -} diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go deleted file mode 100644 index 7cafbeaf1e..0000000000 --- a/apps/cli-go/internal/db/diff/shadow.go +++ /dev/null @@ -1,74 +0,0 @@ -package diff - -import ( - "context" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/utils" -) - -// ShadowSource is a provisioned shadow database, left running for an external -// caller to diff against and then remove. It mirrors the shadow that -// DiffDatabase prepares as the diff "source". -type ShadowSource struct { - // Container is the shadow database container id; the caller MUST remove it - // (e.g. `docker rm -f `) when the diff completes. - Container string - // Source is the connection config for the diff source (the shadow with the - // platform baseline + local migrations applied). - Source pgconn.Config - // TargetOverride, when non-nil, replaces the diff target with a second - // shadow database (contrib_regression with declarative schemas applied). - TargetOverride *pgconn.Config -} - -// PrepareShadowSource provisions the shadow database that DiffDatabase diffs -// against, but returns it running instead of diffing + removing. targetLocal -// mirrors utils.IsLocalDatabase(config). On error the shadow container is -// removed. Declared schemas are applied with the migra seed path; pg-delta -// apply lives in the TypeScript CLI. -func PrepareShadowSource(ctx context.Context, targetLocal bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (ShadowSource, error) { - shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) - if err != nil { - return ShadowSource{}, err - } - ok := false - defer func() { - if !ok { - utils.DockerRemove(shadow) - } - }() - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - return ShadowSource{}, err - } - if err := MigrateShadowDatabase(ctx, shadow, fsys, options...); err != nil { - return ShadowSource{}, err - } - shadowConfig := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.ShadowPort, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - } - var targetOverride *pgconn.Config - if targetLocal { - declared, err := loadDeclaredSchemas(fsys) - if err != nil { - return ShadowSource{}, err - } - if len(declared) > 0 { - override := shadowConfig - override.Database = "contrib_regression" - if err := migrateBaseDatabase(ctx, override, declared, fsys, options...); err != nil { - return ShadowSource{}, err - } - targetOverride = &override - } - } - ok = true - return ShadowSource{Container: shadow, Source: shadowConfig, TargetOverride: targetOverride}, nil -} diff --git a/apps/cli-go/internal/db/diff/templates/migra.sh b/apps/cli-go/internal/db/diff/templates/migra.sh deleted file mode 100755 index e630bd1147..0000000000 --- a/apps/cli-go/internal/db/diff/templates/migra.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh -set -eu - -if [ "${SUPABASE_SSL_DEBUG:-}" = "true" ]; then - [ -n "${SOURCE:-}" ] && source_set=true || source_set=false - [ -n "${TARGET:-}" ] && target_set=true || target_set=false - echo "[ssl-debug] migra.sh uname=$(uname -a)" >&2 - echo "[ssl-debug] migra.sh source_set=$source_set target_set=$target_set schemas=$*" >&2 -fi - -# migra doesn't shutdown gracefully, so kill it ourselves -trap 'kill -9 %1' TERM - -run_migra() { - # additional flags for diffing extensions - [ "$schema" = "extensions" ] && set -- --create-extensions-only --ignore-extension-versions "$@" - migra --with-privileges --unsafe --schema="$schema" "$@" -} - -# accepts command line args as a list of schema to generate -for schema in "$@"; do - # migra exits 2 when differences are found - run_migra "$SOURCE" "$TARGET" || status=$? - if [ "${SUPABASE_SSL_DEBUG:-}" = "true" ]; then - echo "[ssl-debug] migra.sh schema=$schema exit_status=${status:-0}" >&2 - fi - if [ ${status:-2} -ne 2 ]; then - exit $status - fi -done diff --git a/apps/cli-go/internal/db/diff/templates/migra.ts b/apps/cli-go/internal/db/diff/templates/migra.ts deleted file mode 100644 index d67124140f..0000000000 --- a/apps/cli-go/internal/db/diff/templates/migra.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createClient } from "npm:@pgkit/client"; -import { Migration } from "npm:@pgkit/migra"; - -// Avoids error on self-signed certificate -const ca = Deno.env.get("SSL_CA"); -const source = Deno.env.get("SOURCE"); -const target = Deno.env.get("TARGET"); -const sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true"; - -function redactPostgresUrl(raw: string | undefined): string { - if (!raw) return ""; - try { - const u = new URL(raw); - if (u.password) u.password = "xxxxx"; - return u.toString(); - } catch { - return ""; - } -} - -if (sslDebug) { - console.error( - `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`, - ); - console.error( - `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`, - ); - console.error( - `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`, - ); -} - -type PoolClient = { - query: (stmt: string) => Promise; - on: (event: "error", listener: () => void) => void; - off: (event: "error", listener: () => void) => void; -}; -// Step down from login role to postgres and force schema qualified references for -// pg_get_expr on every pooled connection, including one reopened after an idle timeout -const verify = (stmt: string) => (client: PoolClient, done: (err?: Error) => void) => { - // pg-pool drops its error listener during verify, so a socket error must reject, not throw - const ignore = () => {}; - client.on("error", ignore); - client - .query(stmt) - .finally(() => client.off("error", ignore)) - .then( - () => done(), - (err: Error) => { - err.message = `${stmt}: ${err.message}`; - done(err); - }, - ); -}; -const clientBase = createClient(source, { - pgpOptions: { connect: { verify: verify("set search_path = ''") } }, -}); -const clientHead = createClient(target, { - pgpOptions: { - connect: { - ssl: ca && { ca }, - verify: verify("set role postgres; set search_path = ''"), - }, - }, -}); -const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? []; -const excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? []; - -const managedSchemas = ["auth", "realtime", "storage"]; -const extensionSchemas = [ - "pg_catalog", - "extensions", - "pgmq", - "tiger", - "topology", -]; - -try { - const result: string[] = []; - for (const schema of includedSchemas) { - const m = await Migration.create(clientBase, clientHead, { - schema, - ignore_extension_versions: true, - }); - m.set_safety(false); - if (managedSchemas.includes(schema)) { - m.add(m.changes.triggers({ drops_only: true })); - m.add(m.changes.rlspolicies({ drops_only: true })); - m.add(m.changes.rlspolicies({ creations_only: true })); - m.add(m.changes.triggers({ creations_only: true })); - } else { - m.add_all_changes(true); - } - result.push(m.sql); - } - if (includedSchemas.length === 0) { - // Migra does not ignore custom types and triggers created by extensions, so we diff - // them separately. This workaround only applies to a known list of managed schemas. - for (const schema of extensionSchemas) { - const e = await Migration.create(clientBase, clientHead, { - schema, - ignore_extension_versions: true, - }); - e.set_safety(false); - e.add(e.changes.schemas({ creations_only: true })); - e.add_extension_changes(); - result.push(e.sql); - } - // Diff user defined entities in non-managed schemas, including extensions. - const m = await Migration.create(clientBase, clientHead, { - exclude_schema: [ - ...managedSchemas, - ...extensionSchemas, - ...excludedSchemas, - ], - ignore_extension_versions: true, - }); - m.set_safety(false); - m.add_all_changes(true); - result.push(m.sql); - // For managed schemas, we want to include triggers and RLS policies only. - for (const schema of managedSchemas) { - const s = await Migration.create(clientBase, clientHead, { - schema, - ignore_extension_versions: true, - }); - s.set_safety(false); - s.add(s.changes.triggers({ drops_only: true })); - s.add(s.changes.rlspolicies({ drops_only: true })); - s.add(s.changes.rlspolicies({ creations_only: true })); - s.add(s.changes.triggers({ creations_only: true })); - result.push(s.sql); - } - } - console.log(result.join("")); -} catch (e) { - if (sslDebug) { - if (e instanceof Error) { - console.error( - `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? ""}`, - ); - } else { - console.error(`[ssl-debug] migra.ts error=${String(e)}`); - } - } - console.error(e); - console.error("PGDELTA_SCRIPT_ERROR"); -} finally { - await Promise.all([clientHead.end(), clientBase.end()]); -} diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go deleted file mode 100644 index 7ac3f8e4f1..0000000000 --- a/apps/cli-go/internal/db/reset/reset.go +++ /dev/null @@ -1,313 +0,0 @@ -package reset - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" - "github.com/containerd/errdefs" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/migration/apply" - "github.com/supabase/cli/internal/migration/down" - "github.com/supabase/cli/internal/migration/list" - "github.com/supabase/cli/internal/migration/repair" - "github.com/supabase/cli/internal/seed/buckets" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -func Run(ctx context.Context, version string, last uint, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if len(version) > 0 { - if _, err := strconv.Atoi(version); err != nil { - return errors.New(repair.ErrInvalidVersion) - } - if _, err := repair.GetMigrationFile(version, fsys); err != nil { - return err - } - } else if last > 0 { - localMigrations, err := list.LoadLocalVersions(fsys) - if err != nil { - return err - } - if total := uint(len(localMigrations)); last < total { - version = localMigrations[total-last-1] - } else { - // Negative skips all migrations - version = "-" - } - } - if !utils.IsLocalDatabase(config) { - return resetRemote(ctx, version, config, fsys, options...) - } - // Config file is loaded before parsing --linked or --local flags - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - // Reset postgres database because extensions (pg_cron, pg_net) require postgres - if err := resetDatabase(ctx, version, fsys, options...); err != nil { - return err - } - // Seed objects from supabase/buckets directory - if resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); err == nil { - if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { - if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { - return err - } - } - if err := buckets.Run(ctx, "", false, fsys); err != nil { - return err - } - } - branch := utils.GetGitBranch(fsys) - fmt.Fprintln(os.Stderr, "Finished "+utils.Aqua("supabase db reset")+" on branch "+utils.Aqua(branch)+".") - return nil -} - -func resetDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - fmt.Fprintln(os.Stderr, "Resetting local database"+toLogMessage(version)) - if utils.Config.Db.MajorVersion <= 14 { - return resetDatabase14(ctx, version, fsys, options...) - } - return resetDatabase15(ctx, version, fsys, options...) -} - -func toLogMessage(version string) string { - if len(version) > 0 { - return " to version: " + version - } - return "..." -} - -func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if err := recreateDatabase(ctx, options...); err != nil { - return err - } - if err := initDatabase(ctx, options...); err != nil { - return err - } - if err := RestartDatabase(ctx, os.Stderr); err != nil { - return err - } - conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{}, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - return apply.MigrateAndSeed(ctx, version, conn, fsys) -} - -func resetDatabase15(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if err := utils.Docker.ContainerRemove(ctx, utils.DbId, container.RemoveOptions{Force: true}); err != nil { - return errors.Errorf("failed to remove container: %w", err) - } - if err := utils.Docker.VolumeRemove(ctx, utils.DbId, true); err != nil { - return errors.Errorf("failed to remove volume: %w", err) - } - config := start.NewContainerConfig() - hostConfig := start.NewHostConfig() - networkingConfig := network.NetworkingConfig{ - EndpointsConfig: map[string]*network.EndpointSettings{ - utils.NetId: { - Aliases: utils.DbAliases, - }, - }, - } - fmt.Fprintln(os.Stderr, "Recreating database...") - if _, err := utils.DockerStart(ctx, config, hostConfig, networkingConfig, utils.DbId); err != nil { - return err - } - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { - return err - } - if err := start.SetupLocalDatabase(ctx, version, fsys, os.Stderr, options...); err != nil { - return err - } - fmt.Fprintln(os.Stderr, "Restarting containers...") - return restartServices(ctx) -} - -func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { - conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{User: utils.SUPERUSER_ROLE}, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if err := start.InitSchema14(ctx, conn); err != nil { - return err - } - return start.ApplyApiPrivileges(ctx, conn) -} - -// Recreate postgres database by connecting to template1 -func recreateDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { - conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{User: utils.SUPERUSER_ROLE, Database: "template1"}, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if err := DisconnectClients(ctx, conn); err != nil { - return err - } - // We are not dropping roles here because they are cluster level entities. Use stop && start instead. - sql := migration.MigrationFile{ - Statements: []string{ - "DROP DATABASE IF EXISTS postgres WITH (FORCE)", - "CREATE DATABASE postgres WITH OWNER postgres", - "DROP DATABASE IF EXISTS _supabase WITH (FORCE)", - "CREATE DATABASE _supabase WITH OWNER postgres", - }, - } - return sql.ExecBatch(ctx, conn) -} - -const ( - TERMINATE_BACKENDS = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('postgres', '_supabase')" - COUNT_REPLICATION_SLOTS = "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')" -) - -func DisconnectClients(ctx context.Context, conn *pgx.Conn) error { - // Must be executed separately because looping in transaction is unsupported - // https://dba.stackexchange.com/a/11895 - disconn := migration.MigrationFile{ - Statements: []string{ - "ALTER DATABASE postgres ALLOW_CONNECTIONS false", - "ALTER DATABASE _supabase ALLOW_CONNECTIONS false", - TERMINATE_BACKENDS, - }, - } - if err := disconn.ExecBatch(ctx, conn); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code != pgerrcode.InvalidCatalogName { - return errors.Errorf("failed to disconnect clients: %w", err) - } - } - // Wait for WAL senders to drop their replication slots - policy := start.NewBackoffPolicy(ctx, 10*time.Second) - waitForDrop := func() error { - var count int - if err := conn.QueryRow(ctx, COUNT_REPLICATION_SLOTS).Scan(&count); err != nil { - err = errors.Errorf("failed to count replication slots: %w", err) - return &backoff.PermanentError{Err: err} - } else if count > 0 { - return errors.Errorf("replication slots still active: %d", count) - } - return nil - } - return backoff.Retry(waitForDrop, policy) -} - -func RestartDatabase(ctx context.Context, w io.Writer) error { - fmt.Fprintln(w, "Restarting containers...") - // Some extensions must be manually restarted after pg_terminate_backend - // Ref: https://github.com/citusdata/pg_cron/issues/99 - if err := utils.Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{}); err != nil { - return errors.Errorf("failed to restart container: %w", err) - } - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { - return err - } - return restartServices(ctx) -} - -func restartServices(ctx context.Context) error { - // No need to restart PostgREST because it automatically reconnects and listens for schema changes - services := listServicesToRestart() - result := utils.WaitAll(services, func(id string) error { - if err := utils.Docker.ContainerRestart(ctx, id, container.StopOptions{}); err != nil && !errdefs.IsNotFound(err) { - return errors.Errorf("failed to restart %s: %w", id, err) - } - return nil - }) - // Do not wait for service healthy as those services may be excluded from starting - if err := errors.Join(result...); err != nil { - return err - } - return reloadKong(ctx) -} - -func listServicesToRestart() []string { - return []string{utils.StorageId, utils.GotrueId, utils.RealtimeId, utils.PoolerId} -} - -// reloadKong reloads Kong after the restarts above so its nginx re-resolves each -// upstream container's address. Kong caches resolved addresses for the life of a -// worker process, and a restarted container can come back on a different one, -// leaving the gateway returning 502 for that route until Kong restarts. An -// in-place reload (the `functions serve` pattern) keeps the gateway serving -// throughout. https://github.com/supabase/cli/issues/6016 -func reloadKong(ctx context.Context) error { - resp, err := utils.Docker.ContainerInspect(ctx, utils.KongId) - if errdefs.IsNotFound(err) { - // Kong may be excluded from the stack. - return nil - } else if err != nil { - return suggestKongRecovery(errors.Errorf("failed to inspect kong: %w", err)) - } - if !resp.State.Running { - // A stopped gateway has no stale cache to flush. - return nil - } - var out bytes.Buffer - // Reload re-renders nginx.conf from Kong's default template, so it needs the - // template bring-up wrote (start.go:589-592) handed back — otherwise it drops - // that template's email_templates server. https://github.com/supabase/cli/issues/6059 - if err := utils.DockerExecOnceWithStream(ctx, utils.KongId, "", nil, []string{"kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"}, &out, &out); err != nil { - if msg := strings.TrimSpace(out.String()); len(msg) > 0 { - return suggestKongRecovery(errors.Errorf("failed to reload kong: %w:\n%s", err, msg)) - } - return suggestKongRecovery(errors.Errorf("failed to reload kong: %w", err)) - } - return nil -} - -// suggestKongRecovery decorates a gateway-left-unconfirmed failure with the -// advisory next step; the caller-neutral wording also covers branch switch, -// which shares RestartDatabase. -func suggestKongRecovery(err error) error { - utils.CmdSuggestion = fmt.Sprintf( - "Local services restarted, but API routes may return 502 until the gateway reloads.\nTry restarting it with %s, and check %s if the failure persists.", - utils.Aqua("docker restart "+utils.KongId), - utils.Aqua("docker logs "+utils.KongId), - ) - return err -} - -func resetRemote(ctx context.Context, version string, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - msg := "Do you want to reset the remote database?" - if shouldReset, err := utils.NewConsole().PromptYesNo(ctx, msg, false); err != nil { - return err - } else if !shouldReset { - return errors.New(context.Canceled) - } - fmt.Fprintln(os.Stderr, "Resetting remote database"+toLogMessage(version)) - conn, err := utils.ConnectByConfigStream(ctx, config, io.Discard, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - return down.ResetAll(ctx, version, conn, fsys) -} - -func LikeEscapeSchema(schemas []string) (result []string) { - // Treat _ as literal, * as any character - replacer := strings.NewReplacer("_", `\_`, "*", "%") - for _, sch := range schemas { - result = append(result, replacer.Replace(sch)) - } - return result -} diff --git a/apps/cli-go/internal/db/reset/reset_test.go b/apps/cli-go/internal/db/reset/reset_test.go deleted file mode 100644 index 5d672ec15f..0000000000 --- a/apps/cli-go/internal/db/reset/reset_test.go +++ /dev/null @@ -1,568 +0,0 @@ -package reset - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "testing" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/pgtest" - "github.com/supabase/cli/pkg/storage" -) - -func TestResetCommand(t *testing.T) { - utils.Config.Hostname = "127.0.0.1" - utils.Config.Db.Port = 5432 - - var dbConfig = pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "admin", - Password: "password", - Database: "postgres", - } - - t.Run("seeds storage after reset", func(t *testing.T) { - utils.DbId = "test-reset" - utils.Config.Db.MajorVersion = 15 - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). - Reply(http.StatusOK) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), utils.DbId) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Setup mock postgres: with auto_expose_new_tables unset, the default Data API GRANTs - // are revoked by default during database setup. - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockApiPrivilegesRevoke(conn) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - utils.KongId = "test-kong" - for _, container := range listServicesToRestart() { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusOK) - } - // Kong is not running so the reload is skipped (a successful exec attach is not gock-mockable, see TestExecOnce) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{Running: false}, - }}) - // Seeds storage - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.StorageId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - // Run test - err := Run(context.Background(), "", 0, dbConfig, fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on context canceled", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), "", 0, pgconn.Config{Host: "db.supabase.co"}, fsys) - // Check error - assert.ErrorIs(t, err, context.Canceled) - }) - - t.Run("throws error on invalid port", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "y")) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), "", 0, pgconn.Config{Host: "db.supabase.co"}, fsys) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("throws error on db is not started", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusNotFound) - // Run test - err := Run(context.Background(), "", 0, dbConfig, fsys) - // Check error - assert.ErrorIs(t, err, utils.ErrNotRunning) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to recreate", func(t *testing.T) { - utils.DbId = "test-reset" - utils.Config.Db.MajorVersion = 15 - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - ReplyError(errors.New("network error")) - // Run test - err := Run(context.Background(), "", 0, dbConfig, fsys) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestInitDatabase(t *testing.T) { - t.Run("initializes postgres database", func(t *testing.T) { - utils.Config.Db.Port = 54322 - utils.InitialSchemaPg14Sql = "create schema private" - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn) - // Run test - assert.NoError(t, initDatabase(context.Background(), conn.Intercept)) - }) - - t.Run("throws error on connect failure", func(t *testing.T) { - utils.Config.Db.Port = 0 - // Run test - err := initDatabase(context.Background()) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("throws error on duplicate schema", func(t *testing.T) { - utils.Config.Db.Port = 54322 - utils.InitialSchemaPg14Sql = "create schema private" - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.InitialSchemaPg14Sql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) - // Run test - err := initDatabase(context.Background(), conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06)`) - }) -} - -func TestRecreateDatabase(t *testing.T) { - t.Run("resets postgres database", func(t *testing.T) { - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query(TERMINATE_BACKENDS). - Reply("SELECT 1"). - Query(COUNT_REPLICATION_SLOTS). - Reply("SELECT 1", []any{0}). - Query("DROP DATABASE IF EXISTS postgres WITH (FORCE)"). - Reply("DROP DATABASE"). - Query("CREATE DATABASE postgres WITH OWNER postgres"). - Reply("CREATE DATABASE"). - Query("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"). - Reply("DROP DATABASE"). - Query("CREATE DATABASE _supabase WITH OWNER postgres"). - Reply("CREATE DATABASE") - // Run test - assert.NoError(t, recreateDatabase(context.Background(), conn.Intercept)) - }) - - t.Run("throws error on invalid port", func(t *testing.T) { - utils.Config.Db.Port = 0 - assert.ErrorContains(t, recreateDatabase(context.Background()), "invalid port") - }) - - t.Run("continues on disconnecting missing database", func(t *testing.T) { - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - ReplyError(pgerrcode.InvalidCatalogName, `database "_supabase" does not exist`). - Query(TERMINATE_BACKENDS). - Query(COUNT_REPLICATION_SLOTS). - ReplyError(pgerrcode.UndefinedTable, `relation "pg_replication_slots" does not exist`) - // Run test - err := recreateDatabase(context.Background(), conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: relation "pg_replication_slots" does not exist (SQLSTATE 42P01)`) - }) - - t.Run("throws error on failure to disconnect", func(t *testing.T) { - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - ReplyError(pgerrcode.InvalidParameterValue, `cannot disallow connections for current database`). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Query(TERMINATE_BACKENDS) - // Run test - err := recreateDatabase(context.Background(), conn.Intercept) - // Check error - assert.ErrorContains(t, err, "ERROR: cannot disallow connections for current database (SQLSTATE 22023)") - }) - - t.Run("throws error on failure to drop", func(t *testing.T) { - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query(TERMINATE_BACKENDS). - Reply("SELECT 1"). - Query(COUNT_REPLICATION_SLOTS). - Reply("SELECT 1", []any{0}). - Query("DROP DATABASE IF EXISTS postgres WITH (FORCE)"). - ReplyError(pgerrcode.ObjectInUse, `database "postgres" is used by an active logical replication slot`). - Query("CREATE DATABASE postgres WITH OWNER postgres"). - Query("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"). - Reply("DROP DATABASE"). - Query("CREATE DATABASE _supabase WITH OWNER postgres"). - Reply("CREATE DATABASE") - err := recreateDatabase(context.Background(), conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: database "postgres" is used by an active logical replication slot (SQLSTATE 55006)`) - }) -} - -func TestRestartDatabase(t *testing.T) { - t.Run("restarts affected services", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - utils.KongId = "test-kong" - for _, container := range listServicesToRestart() { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusOK) - } - // Kong is not running so the reload is skipped (a successful exec attach is not gock-mockable, see TestExecOnce) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{Running: false}, - }}) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on service restart failure", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - for _, container := range []string{utils.StorageId, utils.GotrueId, utils.RealtimeId} { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusServiceUnavailable) - } - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.PoolerId + "/restart"). - Reply(http.StatusNotFound) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.ErrorContains(t, err, "failed to restart "+utils.StorageId) - assert.ErrorContains(t, err, "failed to restart "+utils.GotrueId) - assert.ErrorContains(t, err, "failed to restart "+utils.RealtimeId) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("skips kong reload when kong is not running", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - utils.KongId = "test-kong" - for _, container := range listServicesToRestart() { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusOK) - } - // Kong is excluded from the stack: no exec follows - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/json"). - Reply(http.StatusNotFound) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on kong inspect failure", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - utils.KongId = "test-kong" - for _, container := range listServicesToRestart() { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusOK) - } - // A daemon error is not the excluded-kong skip case - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/json"). - Reply(http.StatusServiceUnavailable) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.ErrorContains(t, err, "failed to inspect kong") - assert.Contains(t, utils.CmdSuggestion, "API routes may return 502") - assert.Contains(t, utils.CmdSuggestion, "docker restart test-kong") - t.Cleanup(func() { utils.CmdSuggestion = "" }) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on kong reload failure", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Restarts services - utils.StorageId = "test-storage" - utils.GotrueId = "test-auth" - utils.RealtimeId = "test-realtime" - utils.PoolerId = "test-pooler" - utils.KongId = "test-kong" - for _, container := range listServicesToRestart() { - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). - Reply(http.StatusOK) - } - // Kong is up but the reload exec fails - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{Running: true}, - }}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/exec"). - Reply(http.StatusServiceUnavailable) - // The reload must carry bring-up's template — see reloadKong (#6059). - execPath := "/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/exec" - observed := 0 - gock.Observe(func(r *http.Request, mock gock.Mock) { - if r.URL.Path != execPath { - return - } - observed += 1 - var body container.ExecOptions - assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, []string{"kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"}, body.Cmd) - }) - t.Cleanup(func() { gock.Observe(nil) }) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.Equal(t, 1, observed) - assert.ErrorContains(t, err, "failed to reload kong") - assert.Contains(t, utils.CmdSuggestion, "API routes may return 502") - assert.Contains(t, utils.CmdSuggestion, "docker restart test-kong") - t.Cleanup(func() { utils.CmdSuggestion = "" }) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on db restart failure", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // Restarts postgres - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusServiceUnavailable) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.ErrorContains(t, err, "failed to restart container") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on health check timeout", func(t *testing.T) { - utils.DbId = "test-reset" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/test-reset/restart"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-reset/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: false, - Status: "exited", - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-reset/logs"). - Reply(http.StatusServiceUnavailable) - // Run test - err := RestartDatabase(context.Background(), io.Discard) - // Check error - assert.ErrorContains(t, err, "test-reset container is not running: exited") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go deleted file mode 100644 index d4c97ba1d5..0000000000 --- a/apps/cli-go/internal/db/start/start.go +++ /dev/null @@ -1,425 +0,0 @@ -package start - -import ( - "context" - _ "embed" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" - "github.com/containerd/errdefs" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/go-connections/nat" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/migration/apply" - "github.com/supabase/cli/internal/status" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/vault" -) - -var ( - //go:embed templates/schema.sql - initialSchema string - //go:embed templates/webhook.sql - webhookSchema string - //go:embed templates/_supabase.sql - _supabaseSchema string - //go:embed templates/restore.sh - restoreScript string -) - -func Run(ctx context.Context, fromBackup string, fsys afero.Fs) error { - if err := flags.LoadConfig(fsys); err != nil { - return err - } - if err := utils.AssertSupabaseDbIsRunning(); err == nil { - fmt.Fprintln(os.Stderr, "Postgres database is already running.") - return nil - } else if !errors.Is(err, utils.ErrNotRunning) { - return err - } - err := StartDatabase(ctx, fromBackup, fsys, os.Stderr) - if err != nil { - if err := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } - return err -} - -func NewContainerConfig(args ...string) container.Config { - env := []string{ - "POSTGRES_PASSWORD=" + utils.Config.Db.Password, - "POSTGRES_HOST=/var/run/postgresql", - "JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - fmt.Sprintf("JWT_EXP=%d", utils.Config.Auth.JwtExpiry), - } - if len(utils.Config.Db.OrioleDBVersion) > 0 { - env = append(env, - "POSTGRES_INITDB_ARGS=--lc-collate=C --lc-ctype=C", - fmt.Sprintf("S3_ENABLED=%t", true), - "S3_HOST="+utils.Config.Experimental.S3Host, - "S3_REGION="+utils.Config.Experimental.S3Region, - "S3_ACCESS_KEY="+utils.Config.Experimental.S3AccessKey, - "S3_SECRET_KEY="+utils.Config.Experimental.S3SecretKey, - ) - } else if i := strings.IndexByte(utils.Config.Db.Image, ':'); config.VersionCompare(utils.Config.Db.Image[i+1:], "15.8.1.005") < 0 { - env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8") - } - config := container.Config{ - Image: utils.Config.Db.Image, - Env: env, - Healthcheck: &container.HealthConfig{ - Test: []string{"CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"}, - Interval: 10 * time.Second, - Timeout: 2 * time.Second, - Retries: 3, - }, - Entrypoint: []string{"sh", "-c", ` -cat <<'EOF' > /etc/postgresql.schema.sql && \ -cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \ -cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ -docker-entrypoint.sh postgres -D /etc/postgresql ` + strings.Join(args, " ") + ` -` + initialSchema + ` -` + webhookSchema + ` -` + _supabaseSchema + ` -EOF -` + utils.Config.Db.RootKey.Value + ` -EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` -EOF`}, - } - if utils.Config.Db.MajorVersion <= 14 { - config.Entrypoint = []string{"sh", "-c", ` -cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \ -cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ -docker-entrypoint.sh postgres -D /etc/postgresql ` + strings.Join(args, " ") + ` -` + _supabaseSchema + ` -EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` -EOF`} - } - return config -} - -func NewHostConfig() container.HostConfig { - hostPort := strconv.FormatUint(uint64(utils.Config.Db.Port), 10) - hostConfig := container.HostConfig{ - PortBindings: nat.PortMap{"5432/tcp": []nat.PortBinding{{HostPort: hostPort}}}, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}, - Binds: []string{ - utils.DbId + ":/var/lib/postgresql/data", - }, - } - if utils.Config.Db.MajorVersion <= 14 { - hostConfig.Tmpfs = map[string]string{"/docker-entrypoint-initdb.d": ""} - } - return hostConfig -} - -func StartDatabase(ctx context.Context, fromBackup string, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error { - config := NewContainerConfig() - hostConfig := NewHostConfig() - networkingConfig := network.NetworkingConfig{ - EndpointsConfig: map[string]*network.EndpointSettings{ - utils.NetId: { - Aliases: utils.DbAliases, - }, - }, - } - if len(fromBackup) > 0 { - config.Entrypoint = []string{"sh", "-c", ` -cat <<'EOF' > /etc/postgresql.schema.sql && \ -cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \ -cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \ -cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ -docker-entrypoint.sh postgres -D /etc/postgresql -` + initialSchema + ` -` + _supabaseSchema + ` -EOF -` + restoreScript + ` -EOF -` + utils.Config.Db.RootKey.Value + ` -EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` -cron.launch_active_jobs = off -EOF`} - if !filepath.IsAbs(fromBackup) { - fromBackup = filepath.Join(utils.CurrentDirAbs, fromBackup) - } - hostConfig.Binds = append(hostConfig.Binds, utils.ToDockerPath(fromBackup)+":/etc/backup.sql:ro") - } - // Creating volume will not override existing volume, so we must inspect explicitly - _, err := utils.Docker.VolumeInspect(ctx, utils.DbId) - utils.NoBackupVolume = errdefs.IsNotFound(err) - if utils.NoBackupVolume { - fmt.Fprintln(w, "Starting database...") - } else if len(fromBackup) > 0 { - utils.CmdSuggestion = fmt.Sprintf("Run %s to remove existing docker volumes.", utils.Aqua("supabase stop --no-backup")) - return errors.Errorf("backup volume already exists") - } else { - fmt.Fprintln(w, "Starting database from backup...") - } - if _, err := utils.DockerStart(ctx, config, hostConfig, networkingConfig, utils.DbId); err != nil { - return err - } - // Ignore health check because restoring a large backup may take longer than 2 minutes - if err := WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil && len(fromBackup) == 0 { - return err - } - // Initialize if we are on PG14 and there's no existing db volume - if utils.NoBackupVolume && len(fromBackup) == 0 { - if err := SetupLocalDatabase(ctx, "", fsys, w, options...); err != nil { - return err - } - } - return initCurrentBranch(fsys) -} - -func NewBackoffPolicy(ctx context.Context, timeout time.Duration) backoff.BackOff { - policy := backoff.WithMaxRetries( - backoff.NewConstantBackOff(time.Second), - uint64(timeout.Seconds()), - ) - return backoff.WithContext(policy, ctx) -} - -func WaitForHealthyService(ctx context.Context, timeout time.Duration, started ...string) error { - probe := func() error { - var errHealth []error - var unhealthy []string - for _, container := range started { - if err := status.IsServiceReady(ctx, container); err != nil { - unhealthy = append(unhealthy, container) - errHealth = append(errHealth, err) - } - } - started = unhealthy - return errors.Join(errHealth...) - } - policy := NewBackoffPolicy(ctx, timeout) - err := backoff.Retry(probe, policy) - if err != nil && !errors.Is(err, context.Canceled) { - // Print container logs for easier debugging - for _, containerId := range started { - fmt.Fprintln(os.Stderr, containerId, "container logs:") - if err := utils.DockerStreamLogsOnce(context.Background(), containerId, os.Stderr, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } - } - return err -} - -func IsUnhealthyError(err error) bool { - // Health check always returns a joinError - _, ok := err.(interface{ Unwrap() []error }) - return ok -} - -func initCurrentBranch(fsys afero.Fs) error { - // Create _current_branch file to avoid breaking db branch commands - if _, err := fsys.Stat(utils.CurrBranchPath); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return errors.Errorf("failed init current branch: %w", err) - } - return utils.WriteFile(utils.CurrBranchPath, []byte("main"), fsys) -} - -func initSchema(ctx context.Context, conn *pgx.Conn, host string, w io.Writer) error { - fmt.Fprintln(w, "Initialising schema...") - if utils.Config.Db.MajorVersion <= 14 { - if file, err := migration.NewMigrationFromReader(strings.NewReader(utils.GlobalsSql)); err != nil { - return err - } else if err := file.ExecBatch(ctx, conn); err != nil { - return err - } - return InitSchema14(ctx, conn) - } - return initSchema15(ctx, host) -} - -func InitSchema14(ctx context.Context, conn *pgx.Conn) error { - sql := utils.InitialSchemaPg14Sql - if utils.Config.Db.MajorVersion == 13 { - sql = utils.InitialSchemaPg13Sql - } - file, err := migration.NewMigrationFromReader(strings.NewReader(sql)) - if err != nil { - return err - } - return file.ExecBatch(ctx, conn) -} - -func initRealtimeJob(host, jwks string) utils.DockerJob { - return utils.DockerJob{ - Image: utils.Config.Realtime.Image, - Env: []string{ - "PORT=4000", - "DB_HOST=" + host, - "DB_PORT=5432", - "DB_USER=" + utils.SUPERUSER_ROLE, - "DB_PASSWORD=" + utils.Config.Db.Password, - "DB_NAME=postgres", - "DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime", - "DB_ENC_KEY=" + utils.Config.Realtime.EncryptionKey, - fmt.Sprintf("API_JWT_JWKS=%s", jwks), - "API_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "METRICS_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "APP_NAME=realtime", - "SECRET_KEY_BASE=" + utils.Config.Realtime.SecretKeyBase, - "ERL_AFLAGS=" + utils.ToRealtimeEnv(utils.Config.Realtime.IpVersion), - "DNS_NODES=''", - "RLIMIT_NOFILE=", - "SEED_SELF_HOST=true", - "RUN_JANITOR=true", - fmt.Sprintf("MAX_HEADER_LENGTH=%d", utils.Config.Realtime.MaxHeaderLength), - }, - Cmd: []string{"/app/bin/realtime", "eval", fmt.Sprintf(`{:ok, _} = Application.ensure_all_started(:realtime) -{:ok, _} = Realtime.Tenants.health_check("%s")`, utils.Config.Realtime.TenantId)}, - } -} - -func initStorageJob(host string) utils.DockerJob { - return utils.DockerJob{ - Image: utils.Config.Storage.Image, - Env: []string{ - "DB_INSTALL_ROLES=false", - "DB_MIGRATIONS_FREEZE_AT=" + utils.Config.Storage.TargetMigration, - "ANON_KEY=" + utils.Config.Auth.AnonKey.Value, - "SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, - "PGRST_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - fmt.Sprintf("DATABASE_URL=postgresql://supabase_storage_admin:%s@%s:5432/postgres", utils.Config.Db.Password, host), - fmt.Sprintf("FILE_SIZE_LIMIT=%v", utils.Config.Storage.FileSizeLimit), - "STORAGE_BACKEND=file", - "STORAGE_FILE_BACKEND_PATH=/mnt", - "TENANT_ID=stub", - // TODO: https://github.com/supabase/storage-api/issues/55 - "REGION=stub", - "GLOBAL_S3_BUCKET=stub", - }, - Cmd: []string{"node", "dist/scripts/migrate-call.js"}, - } -} - -func initAuthJob(host string) utils.DockerJob { - return utils.DockerJob{ - Image: utils.Config.Auth.Image, - Env: []string{ - "API_EXTERNAL_URL=" + utils.Config.AuthExternalURL(), - "GOTRUE_LOG_LEVEL=error", - "GOTRUE_DB_DRIVER=postgres", - fmt.Sprintf("GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:%s@%s:5432/postgres", utils.Config.Db.Password, host), - "GOTRUE_SITE_URL=" + utils.Config.Auth.SiteUrl, - "GOTRUE_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - }, - Cmd: []string{"gotrue", "migrate"}, - } -} - -func initSchema15(ctx context.Context, host string) error { - // Apply service migrations - var initJobs []utils.DockerJob - if utils.Config.Realtime.Enabled { - jwks, err := utils.Config.Auth.ResolveJWKS(context.Background()) - if err != nil { - return err - } - initJobs = append(initJobs, initRealtimeJob(host, jwks)) - } - if utils.Config.Storage.Enabled { - initJobs = append(initJobs, initStorageJob(host)) - } - if utils.Config.Auth.Enabled { - initJobs = append(initJobs, initAuthJob(host)) - } - logger := utils.GetDebugLogger() - for _, job := range initJobs { - if err := utils.DockerRunJob(ctx, job, io.Discard, logger); err != nil { - return err - } - } - return nil -} - -func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error { - conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{}, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if err := SetupDatabase(ctx, conn, utils.DbId, w, fsys); err != nil { - return err - } - if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { - return err - } - return nil -} - -func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer, fsys afero.Fs) error { - if err := initSchema(ctx, conn, host, w); err != nil { - return err - } - if err := ApplyApiPrivileges(ctx, conn); err != nil { - return err - } - // Create vault secrets first so roles.sql can reference them - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { - return err - } - err := migration.SeedGlobals(ctx, []string{utils.CustomRolesPath}, conn, afero.NewIOFS(fsys)) - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err -} - -// RevokeDefaultDataApiPrivilegesSql matches the SQL that Studio runs at cloud project creation -// when the "Default privileges for new entities" toggle is off. It removes the default GRANTs -// applied by the initial schema so newly-created entities in `public` owned by `postgres` are -// not exposed through the Data API roles until explicit GRANTs are issued. -const RevokeDefaultDataApiPrivilegesSql = ` -alter default privileges for role postgres in schema public - revoke select, insert, update, delete on tables from anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke usage, select on sequences from anon, authenticated, service_role; -alter default privileges for role postgres in schema public - revoke execute on functions from anon, authenticated, service_role; -` - -// ApplyApiPrivileges adjusts the default privileges on the `public` schema to match the -// `[api].auto_expose_new_tables` flag in config.toml. The flag is tri-state to give users a -// safe migration window: -// -// - unset (default): revoke the default Data API GRANTs so newly-created entities in `public` -// require explicit GRANTs to surface through the Data API, matching the new cloud default. -// The implicit default flipped to false on May 30, 2026; the flag is removed entirely in -// October 2026 (always-revoked behaviour). -// - true: explicit opt-in to the legacy behaviour of keeping the bundled initial-schema GRANTs -// in place. Deprecated: config loading warns that the flag is removed on October 30, 2026. -// - false: revoke the default Data API GRANTs (same as unset). Kept so users can pin the new -// behaviour explicitly during the deprecation window. -func ApplyApiPrivileges(ctx context.Context, conn *pgx.Conn) error { - if utils.Config.Api.AutoExposeNewTables != nil && *utils.Config.Api.AutoExposeNewTables { - return nil - } - file, err := migration.NewMigrationFromReader(strings.NewReader(RevokeDefaultDataApiPrivilegesSql)) - if err != nil { - return err - } - return file.ExecBatch(ctx, conn) -} diff --git a/apps/cli-go/internal/db/start/start_test.go b/apps/cli-go/internal/db/start/start_test.go deleted file mode 100644 index 805691b8ec..0000000000 --- a/apps/cli-go/internal/db/start/start_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package start - -import ( - "context" - "errors" - "io" - "net/http" - "os" - "testing" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/volume" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestInitBranch(t *testing.T) { - t.Run("throws error on permission denied", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Run test - err := initCurrentBranch(fsys) - // Check error - assert.ErrorContains(t, err, "operation not permitted") - }) - - t.Run("throws error on stat failure", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.StatErrorFs{DenyPath: utils.CurrBranchPath} - // Run test - err := initCurrentBranch(fsys) - // Check error - assert.ErrorContains(t, err, "permission denied") - }) - - t.Run("throws error on write failure", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.CurrBranchPath} - // Run test - err := initCurrentBranch(fsys) - // Check error - assert.ErrorContains(t, err, "permission denied") - }) -} - -func TestStartDatabase(t *testing.T) { - t.Run("initialize main branch", func(t *testing.T) { - utils.Config.Db.MajorVersion = 15 - utils.DbId = "supabase_db_test" - utils.Config.Db.Port = 5432 - // Setup in-memory fs - fsys := afero.NewMemMapFs() - roles := "create role test" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). - Reply(http.StatusNotFound). - JSON(volume.Volume{}) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), utils.DbId) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Realtime.Image), "test-realtime") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-realtime", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Storage.Image), "test-storage") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-storage", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Auth.Image), "test-auth") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-auth", "")) - // Setup mock postgres: with auto_expose_new_tables unset, the implicit default revokes - // the default Data API GRANTs before seeding roles. - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") - // Run test - err := StartDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // Check current branch - contents, err := afero.ReadFile(fsys, utils.CurrBranchPath) - assert.NoError(t, err) - assert.Equal(t, []byte("main"), contents) - }) - - t.Run("recover from backup volume", func(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - utils.DbId = "supabase_db_test" - utils.Config.Db.Port = 5432 - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). - Reply(http.StatusOK). - JSON(volume.Volume{}) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), utils.DbId) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - // Run test - err := StartDatabase(context.Background(), "", fsys, io.Discard) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // Check current branch - contents, err := afero.ReadFile(fsys, utils.CurrBranchPath) - assert.NoError(t, err) - assert.Equal(t, []byte("main"), contents) - }) - - t.Run("throws error on start failure", func(t *testing.T) { - utils.Config.Db.MajorVersion = 15 - utils.DbId = "supabase_db_test" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). - ReplyError(errors.New("network error")) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - Reply(http.StatusServiceUnavailable) - // Run test - err := StartDatabase(context.Background(), "", fsys, io.Discard) - // Check error - assert.ErrorContains(t, err, "request returned 503 Service Unavailable for API route and version") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestStartCommand(t *testing.T) { - t.Run("throws error on malformed config", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) - // Run test - err := Run(context.Background(), "", fsys) - // Check error - assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") - }) - - t.Run("throws error on missing docker", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - ReplyError(errors.New("network error")) - // Run test - err := Run(context.Background(), "", fsys) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("exits if already started", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Run test - err := Run(context.Background(), "", fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on start failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/"). - Reply(http.StatusNotFound) - // Fail to start - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes/"). - ReplyError(errors.New("network error")) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errors.New("network error")) - // Cleanup resources - apitest.MockDockerStop(utils.Docker) - // Run test - err := Run(context.Background(), "", fsys) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestSetupDatabase(t *testing.T) { - utils.Config.Db.MajorVersion = 15 - - t.Run("revokes default data api privileges when auto_expose_new_tables is unset", func(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - utils.Config.Api.AutoExposeNewTables = nil - defer func() { - utils.Config.Db.MajorVersion = 15 - }() - utils.Config.Db.Port = 5432 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - roles := "create role postgres" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) - // Setup mock postgres: with the flag unset, the implicit default now revokes the - // default Data API GRANTs (the May 30 2026 flip) between initial schema and roles.sql - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") - // Run test - err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("keeps default data api privileges when auto_expose_new_tables is true", func(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - flag := true - utils.Config.Api.AutoExposeNewTables = &flag - defer func() { - utils.Config.Db.MajorVersion = 15 - utils.Config.Api.AutoExposeNewTables = nil - }() - utils.Config.Db.Port = 5432 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - roles := "create role postgres" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) - // Setup mock postgres: explicit true opts into the legacy behaviour, so no revoke SQL - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA"). - Query(roles). - Reply("CREATE ROLE") - // Run test - err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("revokes default data api privileges when auto_expose_new_tables is false", func(t *testing.T) { - utils.Config.Db.MajorVersion = 14 - flag := false - utils.Config.Api.AutoExposeNewTables = &flag - defer func() { - utils.Config.Db.MajorVersion = 15 - utils.Config.Api.AutoExposeNewTables = nil - }() - utils.Config.Db.Port = 5432 - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - roles := "create role postgres" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) - // Setup mock postgres: the revoke SQL must execute between the initial schema and roles.sql - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") - // Run test - err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on connect failure", func(t *testing.T) { - utils.Config.Db.Port = 0 - // Run test - err := SetupLocalDatabase(context.Background(), "", nil, io.Discard) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("throws error on init failure", func(t *testing.T) { - utils.Config.Realtime.Enabled = true - utils.Config.Db.Port = 5432 - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Realtime.Image) + "/json"). - ReplyError(errors.New("network error")) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - err := SetupLocalDatabase(context.Background(), "", nil, io.Discard, conn.Intercept) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on read failure", func(t *testing.T) { - utils.Config.Db.Port = 5432 - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.CustomRolesPath} - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Realtime.Image), "test-realtime") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-realtime", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Storage.Image), "test-storage") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-storage", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Auth.Image), "test-auth") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-auth", "")) - // Setup mock postgres: the default-privilege revoke runs before roles.sql is read - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockApiPrivilegesRevoke(conn) - // Run test - err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} -func TestStartDatabaseWithCustomSettings(t *testing.T) { - t.Run("starts database with custom MaxConnections", func(t *testing.T) { - // Setup - utils.Config.Db.MajorVersion = 15 - utils.DbId = "supabase_db_test" - utils.Config.Db.Port = 5432 - utils.Config.Db.Settings.MaxConnections = cast.Ptr(uint(50)) - - // Setup in-memory fs - fsys := afero.NewMemMapFs() - - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId). - Reply(http.StatusNotFound). - JSON(volume.Volume{}) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), utils.DbId) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Realtime.Image), "test-realtime") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-realtime", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Storage.Image), "test-storage") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-storage", "")) - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Auth.Image), "test-auth") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-auth", "")) - // Setup mock postgres: with auto_expose_new_tables unset, the default Data API GRANTs - // are revoked by default. - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockApiPrivilegesRevoke(conn) - - // Run test - err := StartDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) - - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - - // Check if the custom MaxConnections setting was applied - config := NewContainerConfig() - assert.Contains(t, config.Entrypoint[2], "max_connections = 50") - }) -} diff --git a/apps/cli-go/internal/db/start/templates/_supabase.sql b/apps/cli-go/internal/db/start/templates/_supabase.sql deleted file mode 100644 index 6e5d8487ba..0000000000 --- a/apps/cli-go/internal/db/start/templates/_supabase.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE DATABASE _supabase WITH OWNER postgres; - --- Switch to the newly created _supabase database -\c _supabase --- Create schemas in _supabase database for --- internals tools and reports to not overload user database --- with non-user activity -CREATE SCHEMA IF NOT EXISTS _analytics; -ALTER SCHEMA _analytics OWNER TO postgres; - -CREATE SCHEMA IF NOT EXISTS _supavisor; -ALTER SCHEMA _supavisor OWNER TO postgres; -\c postgres diff --git a/apps/cli-go/internal/db/start/templates/restore.sh b/apps/cli-go/internal/db/start/templates/restore.sh deleted file mode 100755 index 3a1729ad97..0000000000 --- a/apps/cli-go/internal/db/start/templates/restore.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/sh -set -eu - -####################################### -# Used by both ami and docker builds to initialise database schema. -# Env vars: -# POSTGRES_DB defaults to postgres -# POSTGRES_HOST defaults to localhost -# POSTGRES_PORT defaults to 5432 -# POSTGRES_PASSWORD defaults to "" -# USE_DBMATE defaults to "" -# Exit code: -# 0 if migration succeeds, non-zero on error. -####################################### - -export PGDATABASE="${POSTGRES_DB:-postgres}" -export PGHOST="${POSTGRES_HOST:-localhost}" -export PGPORT="${POSTGRES_PORT:-5432}" -export PGPASSWORD="${POSTGRES_PASSWORD:-}" - -echo "$0: restoring roles" -cat "/etc/backup.sql" \ -| grep 'CREATE ROLE' \ -| grep -v 'supabase_admin' \ -| sed -E 's/^(CREATE ROLE postgres);/\1 WITH SUPERUSER;/' \ -| psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin - -echo "$0: restoring schema" -cat "/etc/backup.sql" \ -| sed -E 's/^\\(un)?restrict .*$/-- &/' \ -| sed -E 's/^CREATE VIEW /CREATE OR REPLACE VIEW /' \ -| sed -E 's/^CREATE FUNCTION /CREATE OR REPLACE FUNCTION /' \ -| sed -E 's/^CREATE TRIGGER /CREATE OR REPLACE TRIGGER /' \ -| sed -E 's/^GRANT ALL ON FUNCTION graphql_public\./-- &/' \ -| sed -E 's/^CREATE ROLE /-- &/' \ -| sed -e '/ALTER ROLE postgres WITH / { h; $p; d; }' -e '$G' \ -| psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin - -# run any post migration script to update role passwords -postinit="/etc/postgresql.schema.sql" -if [ -e "$postinit" ]; then - echo "$0: running $postinit" - psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -f "$postinit" -fi diff --git a/apps/cli-go/internal/db/start/templates/schema.sql b/apps/cli-go/internal/db/start/templates/schema.sql deleted file mode 100644 index dee166ae58..0000000000 --- a/apps/cli-go/internal/db/start/templates/schema.sql +++ /dev/null @@ -1,17 +0,0 @@ -\set pgpass `echo "$PGPASSWORD"` -\set jwt_secret `echo "$JWT_SECRET"` -\set jwt_exp `echo "$JWT_EXP"` - -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; - -ALTER USER postgres WITH PASSWORD :'pgpass'; -ALTER USER authenticator WITH PASSWORD :'pgpass'; -ALTER USER pgbouncer WITH PASSWORD :'pgpass'; -ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_replication_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_read_only_user WITH PASSWORD :'pgpass'; - -create schema if not exists _realtime; -alter schema _realtime owner to postgres; diff --git a/apps/cli-go/internal/db/start/templates/webhook.sql b/apps/cli-go/internal/db/start/templates/webhook.sql deleted file mode 100644 index 52cd097473..0000000000 --- a/apps/cli-go/internal/db/start/templates/webhook.sql +++ /dev/null @@ -1,232 +0,0 @@ -BEGIN; - --- Create pg_net extension -CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; - --- Create supabase_functions schema -CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; - -GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; - --- supabase_functions.migrations definition -CREATE TABLE supabase_functions.migrations ( - version text PRIMARY KEY, - inserted_at timestamptz NOT NULL DEFAULT NOW() -); - --- Initial supabase_functions migration -INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); - --- supabase_functions.hooks definition -CREATE TABLE supabase_functions.hooks ( - id bigserial PRIMARY KEY, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - request_id bigint -); -CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); -CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); -COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; - -CREATE FUNCTION supabase_functions.http_request() - RETURNS trigger - LANGUAGE plpgsql - AS $function$ - DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; - BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - - RETURN NEW; - END -$function$; - --- Supabase super admin -DO -$$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_functions_admin' - ) - THEN - CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; - END IF; -END -$$; - -GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; -GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; -ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; -ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; -ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; -ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; -GRANT supabase_functions_admin TO postgres; - --- Remove unused supabase_pg_net_admin role -DO -$$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_pg_net_admin' - ) - THEN - REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; - DROP OWNED BY supabase_pg_net_admin; - DROP ROLE supabase_pg_net_admin; - END IF; -END -$$; - --- pg_net grants when extension is already enabled -DO -$$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_extension - WHERE extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END -$$; - --- Event trigger for pg_net -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() -RETURNS event_trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$$; -COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; - -DO -$$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_event_trigger - WHERE evtname = 'issue_pg_net_access' - ) THEN - CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') - EXECUTE PROCEDURE extensions.grant_pg_net_access(); - END IF; -END -$$; - -INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); - -ALTER function supabase_functions.http_request() SECURITY DEFINER; -ALTER function supabase_functions.http_request() SET search_path = supabase_functions; -REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; - -COMMIT; diff --git a/apps/cli-go/internal/debug/http.go b/apps/cli-go/internal/debug/http.go deleted file mode 100644 index b22f6bbc8a..0000000000 --- a/apps/cli-go/internal/debug/http.go +++ /dev/null @@ -1,24 +0,0 @@ -package debug - -import ( - "log" - "net/http" - "os" -) - -type debugTransport struct { - http.RoundTripper - logger *log.Logger -} - -func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.logger.Printf("%s: %s\n", req.Method, req.URL) - return t.RoundTripper.RoundTrip(req) -} - -func NewTransport() http.RoundTripper { - return &debugTransport{ - http.DefaultTransport, - log.New(os.Stderr, "HTTP ", log.LstdFlags|log.Lmsgprefix), - } -} diff --git a/apps/cli-go/internal/debug/postgres.go b/apps/cli-go/internal/debug/postgres.go deleted file mode 100644 index 3d774cb309..0000000000 --- a/apps/cli-go/internal/debug/postgres.go +++ /dev/null @@ -1,262 +0,0 @@ -package debug - -import ( - "context" - "crypto/tls" - "encoding/binary" - "encoding/json" - "errors" - "io" - "log" - "net" - "os" - "time" - - "github.com/jackc/pgproto3/v2" - "github.com/jackc/pgx/v4" - "google.golang.org/grpc/test/bufconn" -) - -type Proxy struct { - dialContext func(ctx context.Context, network, addr string) (net.Conn, error) - // tlsPlan is pgconn's TLS config per attempt, primary first. DialFunc walks it - // so sslmode=allow keeps its plaintext-then-TLS retry, and completes the - // handshake itself, leaving logged frames plaintext in memory but not on the wire. - tlsPlan []*tls.Config - attempts map[string]int - errChan chan error -} - -func NewProxy() Proxy { - dialer := net.Dialer{} - return Proxy{ - dialContext: dialer.DialContext, - attempts: map[string]int{}, - errChan: make(chan error, 1), - } -} - -func SetupPGX(config *pgx.ConnConfig) { - tlsPlan := []*tls.Config{config.TLSConfig} - for _, fallback := range config.Fallbacks { - if fallback.Host != config.Host { - break - } - // ConnectByUrl drops plaintext fallbacks once the primary is TLS, and it - // runs after this, so apply the same rule here or --debug reinstates them. - if config.TLSConfig != nil && fallback.TLSConfig == nil { - continue - } - tlsPlan = append(tlsPlan, fallback.TLSConfig) - } - proxy := Proxy{ - dialContext: config.DialFunc, - tlsPlan: tlsPlan, - attempts: map[string]int{}, - errChan: make(chan error, 1), - } - config.DialFunc = proxy.DialFunc - // pgx must not negotiate TLS again over the in-memory pipe; clearing these - // *without* DialFunc's handshake is what downgraded sslmode=require (#5872). - config.TLSConfig = nil - // pgconn reads Fallbacks independently of TLSConfig, so a restored sslmode=allow - // would still retry TLS through this plaintext proxy. - for _, fallback := range config.Fallbacks { - fallback.TLSConfig = nil - } -} - -// nextTLSConfig returns the TLS config for this attempt against addr. pgconn dials -// each plan entry once per resolved address, in plan order, so a per-address -// counter replays the same TLS/plaintext sequence it would have used itself. -func (p *Proxy) nextTLSConfig(addr string) *tls.Config { - if p.attempts == nil { - p.attempts = map[string]int{} - } - if len(p.tlsPlan) == 0 { - return nil - } - i := p.attempts[addr] - p.attempts[addr]++ - // pgconn redials an addr for the target_session_attrs retry, so clamp rather - // than fall off the plan into plaintext. - if i >= len(p.tlsPlan) { - i = len(p.tlsPlan) - 1 - } - return p.tlsPlan[i] -} - -func (p *Proxy) DialFunc(ctx context.Context, network, addr string) (net.Conn, error) { - tlsConfig := p.nextTLSConfig(addr) - serverConn, err := p.dialContext(ctx, network, addr) - if err != nil { - return nil, err - } - if tlsConfig != nil { - if serverConn, err = startTLS(ctx, serverConn, tlsConfig); err != nil { - return nil, err - } - } - - const bufSize = 1024 * 1024 - ln := bufconn.Listen(bufSize) - go func() { - defer serverConn.Close() - clientConn, err := ln.Accept() - if err != nil { - // Unreachable code as bufconn never throws, but just in case - panic(err) - } - defer clientConn.Close() - - backend := NewBackend(clientConn) - frontend := NewFrontend(serverConn) - go backend.forward(frontend, p.errChan) - go frontend.forward(backend, p.errChan) - - for { - if err := <-p.errChan; err != nil && - // Since pgx closes connection first, every EOF is seen as unexpected - !errors.Is(err, io.ErrUnexpectedEOF) && - // Frontend might receive a reply after pgx closes the connection, in - // which case the backend will write to a closed pipe. So ignore. - !errors.Is(err, io.ErrClosedPipe) { - panic(err) - } - } - }() - - return ln.DialContext(ctx) -} - -// libpq's SSLRequest message code, PG_PROTOCOL(1234,5679). -const sslRequestCode = 80877103 - -// startTLS mirrors pgconn's own startTLS (pgconn@v1.14.3/pgconn.go:406-419), but -// handshakes eagerly so a refusal or bad certificate fails the dial rather than -// surfacing as a parse error inside the forwarding goroutines. -func startTLS(ctx context.Context, conn net.Conn, tlsConfig *tls.Config) (net.Conn, error) { - // pgconn only watches ctx once DialFunc returns, so bound the negotiation here - // or an endpoint that accepts TCP and never replies hangs the read forever. - if deadline, ok := ctx.Deadline(); ok { - if err := conn.SetDeadline(deadline); err != nil { - conn.Close() - return nil, err - } - defer func() { _ = conn.SetDeadline(time.Time{}) }() - } - if err := binary.Write(conn, binary.BigEndian, []int32{8, sslRequestCode}); err != nil { - conn.Close() - return nil, err - } - response := make([]byte, 1) - if _, err := io.ReadFull(conn, response); err != nil { - conn.Close() - return nil, err - } - if response[0] != 'S' { - conn.Close() - return nil, errors.New("server refused TLS connection") - } - tlsConn := tls.Client(conn, tlsConfig) - if err := tlsConn.HandshakeContext(ctx); err != nil { - conn.Close() - return nil, err - } - return tlsConn, nil -} - -type Backend struct { - *pgproto3.Backend - logger *log.Logger -} - -func NewBackend(clientConn net.Conn) Backend { - return Backend{ - pgproto3.NewBackend(pgproto3.NewChunkReader(clientConn), clientConn), - log.New(os.Stderr, "PG Recv: ", log.LstdFlags|log.Lmsgprefix), - } -} - -func (b *Backend) forward(frontend Frontend, errChan chan error) { - startupMessage, err := b.ReceiveStartupMessage() - if err != nil { - errChan <- err - return - } - - buf, err := json.Marshal(startupMessage) - if err != nil { - errChan <- err - return - } - frontend.logger.Println(string(buf)) - - if err = frontend.Send(startupMessage); err != nil { - errChan <- err - return - } - - for { - msg, err := b.Receive() - if err != nil { - errChan <- err - return - } - - buf, err := json.Marshal(msg) - if err != nil { - errChan <- err - return - } - frontend.logger.Println(string(buf)) - - if err = frontend.Send(msg); err != nil { - errChan <- err - return - } - } -} - -type Frontend struct { - *pgproto3.Frontend - logger *log.Logger -} - -func NewFrontend(serverConn net.Conn) Frontend { - return Frontend{ - pgproto3.NewFrontend(pgproto3.NewChunkReader(serverConn), serverConn), - log.New(os.Stderr, "PG Send: ", log.LstdFlags|log.Lmsgprefix), - } -} - -func (f *Frontend) forward(backend Backend, errChan chan error) { - for { - msg, err := f.Receive() - if err != nil { - errChan <- err - return - } - - buf, err := json.Marshal(msg) - if err != nil { - errChan <- err - return - } - backend.logger.Println(string(buf)) - - if _, ok := msg.(pgproto3.AuthenticationResponseMessage); ok { - // Set the authentication type so the next backend.Receive() will - // properly decode the appropriate 'p' message. - if err := backend.SetAuthType(f.GetAuthType()); err != nil { - errChan <- err - return - } - } - - if err := backend.Send(msg); err != nil { - errChan <- err - return - } - } -} diff --git a/apps/cli-go/internal/debug/postgres_test.go b/apps/cli-go/internal/debug/postgres_test.go deleted file mode 100644 index 87a29c3dbe..0000000000 --- a/apps/cli-go/internal/debug/postgres_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package debug - -import ( - "context" - "encoding/binary" - "io" - "net" - "testing" - - "github.com/jackc/pgx/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestPostgresProxy(t *testing.T) { - const postgresUrl = "postgresql://postgres:password@127.0.0.1:5432/postgres" - - t.Run("forwards messages between frontend and backend", func(t *testing.T) { - // Parse connection url - config, err := pgx.ParseConfig(postgresUrl) - require.NoError(t, err) - // Setup postgres mock - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Intercept(config) - // Run test - SetupPGX(config) - ctx := context.Background() - proxy, err := pgx.ConnectConfig(ctx, config) - assert.NoError(t, err) - assert.NoError(t, proxy.Close(ctx)) - }) - - t.Run("negotiates TLS itself instead of downgrading", func(t *testing.T) { - config, err := pgx.ParseConfig(postgresUrl + "?sslmode=require") - require.NoError(t, err) - require.NotNil(t, config.TLSConfig) - // Run test - SetupPGX(config) - assert.Nil(t, config.TLSConfig) - for _, fallback := range config.Fallbacks { - assert.Nil(t, fallback.TLSConfig) - } - // A server refusing TLS must fail the dial, not continue in plaintext. - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - go func() { - conn, err := ln.Accept() - if err != nil { - return - } - defer conn.Close() - request := make([]byte, 8) - if _, err := io.ReadFull(conn, request); err != nil { - return - } - assert.Equal(t, uint32(sslRequestCode), binary.BigEndian.Uint32(request[4:])) - _, _ = conn.Write([]byte("N")) - }() - _, err = config.DialFunc(context.Background(), "tcp", ln.Addr().String()) - assert.ErrorContains(t, err, "server refused TLS connection") - }) -} diff --git a/apps/cli-go/internal/functions/download/download.go b/apps/cli-go/internal/functions/download/download.go deleted file mode 100644 index 0f497ee699..0000000000 --- a/apps/cli-go/internal/functions/download/download.go +++ /dev/null @@ -1,650 +0,0 @@ -package download - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime" - "mime/multipart" - "net/http" - "net/textproto" - "net/url" - "os" - "os/exec" - "path" - "path/filepath" - "strings" - - "github.com/andybalholm/brotli" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/go-units" - "github.com/go-errors/errors" - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/api" -) - -var ( - legacyEntrypointPath = "file:///src/index.ts" - legacyImportMapPath = "file:///src/import_map.json" -) - -// ErrUnsafeDownloadPath is returned when a downloaded Function file's path, -// as reported by the server via the Supabase-Path header or the -// Content-Disposition filename, would resolve outside utils.FunctionsDir -// once joined and cleaned, or would require following an existing symlink -// to get there. The server response is not trusted input, so any path that -// looks like it's trying to escape the functions directory is rejected -// rather than sanitized. -var ErrUnsafeDownloadPath = errors.New("invalid path in server response") - -func RunLegacy(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error { - // 1. Sanity checks. - { - if err := utils.ValidateFunctionSlug(slug); err != nil { - return err - } - } - if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil { - return err - } - - scriptDir, err := utils.CopyDenoScripts(ctx, fsys) - if err != nil { - return err - } - - // 2. Download Function. - if err := downloadFunction(ctx, projectRef, slug, scriptDir.ExtractPath); err != nil { - return err - } - - fmt.Fprintf(os.Stderr, "Downloaded Function %s from project %s.\n", utils.Aqua(slug), utils.Aqua(projectRef)) - return nil -} - -func getFunctionMetadata(ctx context.Context, projectRef, slug string) (*api.FunctionSlugResponseOutput, error) { - resp, err := utils.GetSupabase().V1GetAFunctionWithResponse(ctx, projectRef, slug) - if err != nil { - return nil, errors.Errorf("failed to get function metadata: %w", err) - } - - switch resp.StatusCode() { - case http.StatusNotFound: - return nil, errors.Errorf("Function %s does not exist on the Supabase project.", utils.Aqua(slug)) - case http.StatusOK: - break - default: - return nil, errors.Errorf("Failed to download Function %s on the Supabase project: %s", utils.Aqua(slug), string(resp.Body)) - } - - if resp.JSON200.EntrypointPath == nil { - resp.JSON200.EntrypointPath = &legacyEntrypointPath - } - if resp.JSON200.ImportMapPath == nil { - resp.JSON200.ImportMapPath = &legacyImportMapPath - } - return resp.JSON200, nil -} - -func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath string) error { - fmt.Fprintln(os.Stderr, "Downloading function:", utils.Bold(slug)) - denoPath, err := utils.GetDenoPath() - if err != nil { - return err - } - - meta, err := getFunctionMetadata(ctx, projectRef, slug) - if err != nil { - return err - } - - resp, err := utils.GetSupabase().V1GetAFunctionBodyWithResponse(ctx, projectRef, slug) - if err != nil { - return errors.Errorf("failed to get function body: %w", err) - } - if resp.StatusCode() != http.StatusOK { - return errors.New("Unexpected error downloading Function: " + string(resp.Body)) - } - - resBuf := bytes.NewReader(resp.Body) - funcDir := filepath.Join(utils.FunctionsDir, slug) - args := []string{"run", "-A", extractScriptPath, funcDir, *meta.EntrypointPath, utils.FunctionsDir} - cmd := exec.CommandContext(ctx, denoPath, args...) - var errBuf bytes.Buffer - cmd.Stdin = resBuf - cmd.Stdout = os.Stdout - cmd.Stderr = &errBuf - if err := cmd.Run(); err != nil { - return errors.Errorf("Error downloading function: %w\n%v", err, errBuf.String()) - } - return nil -} - -func Run(ctx context.Context, slug, projectRef string, useLegacyBundle, useDocker bool, fsys afero.Fs) error { - // Sanity check - if err := flags.LoadConfig(fsys); err != nil { - return err - } - - // Defaults to server-side unbundling with multipart/form-data - downloader := downloadWithServerSideUnbundle - if useLegacyBundle { - downloader = RunLegacy - } else if useDocker { - if utils.IsDockerRunning(ctx) { - // Download eszip file for client-side unbundling with edge-runtime - downloader = downloadWithDockerUnbundle - } else { - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running") - } - } - - if len(slug) > 0 { - return downloader(ctx, slug, projectRef, fsys) - } - return downloadAll(ctx, projectRef, fsys, downloader) -} - -func downloadAll(ctx context.Context, projectRef string, fsys afero.Fs, downloader func(context.Context, string, string, afero.Fs) error) error { - resp, err := utils.GetSupabase().V1ListAllFunctionsWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to list functions: %w", err) - } - if resp.JSON200 == nil { - return errors.Errorf("unexpected list functions status %d: %s", resp.StatusCode(), string(resp.Body)) - } - - functions := *resp.JSON200 - if len(functions) == 0 { - fmt.Fprintln(os.Stderr, "No functions found in project ", utils.Aqua(projectRef)) - return nil - } - - fmt.Fprintf(os.Stderr, "Found %d function(s) to download\n", len(functions)) - for _, f := range functions { - // f.Slug comes straight from the Management API response, which - // this threat model treats as untrusted: a malicious or corrupted - // response (or a MITM) could return a slug containing ".." or "/" - // segments. Every downloader below joins this value into a - // filesystem path (utils.TempDir for downloadOne, utils.FunctionsDir - // for downloadWithServerSideUnbundle) before any validation of its - // own, so it must be rejected here -- the single point where it - // enters this dispatch logic -- rather than relying on each - // downstream path-construction site to defend itself. - if err := utils.ValidateFunctionSlug(f.Slug); err != nil { - utils.CmdSuggestion = fmt.Sprintf( - "The Supabase API returned an unexpected function slug (%s). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.", - utils.Aqua(f.Slug), - ) - return errors.Errorf("failed to download function %s: %w", f.Slug, err) - } - if err := downloader(ctx, f.Slug, projectRef, fsys); err != nil { - return err - } - } - - fmt.Fprintln(os.Stderr, "Successfully downloaded all functions from project", utils.Aqua(projectRef)) - return nil -} - -func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error { - eszipPath, err := downloadOne(ctx, slug, projectRef, fsys) - if err != nil { - return err - } - if !viper.GetBool("DEBUG") { - defer func() { - if err := fsys.Remove(eszipPath); err != nil { - fmt.Fprintln(os.Stderr, err) - } - }() - } - // Extract eszip to functions directory - err = extractOne(ctx, slug, eszipPath) - if err != nil { - utils.CmdSuggestion += suggestLegacyBundle(slug) - } - return err -} - -func downloadOne(ctx context.Context, slug, projectRef string, fsys afero.Fs) (string, error) { - fmt.Fprintln(os.Stderr, "Downloading function:", utils.Bold(slug)) - resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug) - if err != nil { - return "", errors.Errorf("failed to get function body: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", errors.Errorf("Error status %d: unexpected error downloading Function", resp.StatusCode) - } - return "", errors.Errorf("Error status %d: %s", resp.StatusCode, string(body)) - } - r := io.Reader(resp.Body) - if strings.EqualFold(resp.Header.Get("Content-Encoding"), "br") { - r = brotli.NewReader(resp.Body) - } - // Create temp file to store downloaded eszip - eszipPath := filepath.Join(utils.TempDir, fmt.Sprintf("output_%s.eszip", slug)) - if err := utils.MkdirIfNotExistFS(fsys, utils.TempDir); err != nil { - return "", err - } - if err := afero.WriteReader(fsys, eszipPath, r); err != nil { - return "", errors.Errorf("failed to download file: %w", err) - } - return eszipPath, nil -} - -func extractOne(ctx context.Context, slug, eszipPath string) error { - hostFuncDirPath, err := filepath.Abs(utils.FunctionsDir) - if err != nil { - return errors.Errorf("failed to resolve functions path: %w", err) - } - - hostEszipPath, err := filepath.Abs(eszipPath) - if err != nil { - return errors.Errorf("failed to resolve eszip path: %w", err) - } - dockerEszipPath := path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath)) - dockerOutputPath := path.Join(utils.DockerDenoDir, slug) - - binds := []string{ - // Reuse deno cache directory, ie. DENO_DIR, between container restarts - // https://denolib.gitbook.io/guide/advanced/deno_dir-code-fetch-and-cache - utils.EdgeRuntimeId + ":/root/.cache/deno:rw", - hostEszipPath + ":" + dockerEszipPath + ":ro", - hostFuncDirPath + ":" + utils.DockerDenoDir + ":rw", - } - - return utils.DockerRunOnceWithConfig( - ctx, - container.Config{ - Image: utils.Config.EdgeRuntime.Image, - Cmd: []string{"unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath}, - }, - container.HostConfig{ - Binds: binds, - }, - network.NetworkingConfig{}, - "", - os.Stdout, - getErrorLogger(), - ) -} - -func getErrorLogger() io.Writer { - if utils.Config.EdgeRuntime.DenoVersion > 1 { - return os.Stderr - } - // Additional error handling for deno v1 - r, w := io.Pipe() - go func() { - logs := bufio.NewScanner(r) - for logs.Scan() { - line := logs.Text() - fmt.Fprintln(os.Stderr, line) - if strings.EqualFold(line, "invalid eszip v2") { - utils.CmdSuggestion = suggestDenoV2() - } - } - if err := logs.Err(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - }() - return w -} - -func suggestDenoV2() string { - return fmt.Sprintf(`Please use deno v2 in %s to download this Function: - -[edge_runtime] -deno_version = 2 -`, utils.Bold(utils.ConfigPath)) -} - -func suggestLegacyBundle(slug string) string { - return fmt.Sprintf("\nIf your function is deployed using CLI < 1.120.0, trying running %s instead.", utils.Aqua("supabase functions download --legacy-bundle "+slug)) -} - -func suggestUnsafeDownloadPath() string { - return "This usually indicates a malformed or unexpected API response. If you're using a self-hosted instance, verify your API URL is correct." -} - -type bundleMetadata struct { - EntrypointPath string `json:"deno2_entrypoint_path,omitempty"` -} - -// New server-side unbundle implementation that mirrors Studio's entrypoint-based -// base-dir + relative path behaviour. -func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error { - fmt.Fprintln(os.Stderr, "Downloading Function:", utils.Bold(slug)) - - form, err := readForm(ctx, projectRef, slug) - if err != nil { - return err - } - defer func() { - if err := form.RemoveAll(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - }() - - // Read entrypoint path from deno2 bundles - metadata := bundleMetadata{} - if data, ok := form.Value["metadata"]; ok { - for _, part := range data { - if err := json.Unmarshal([]byte(part), &metadata); err != nil { - return errors.Errorf("failed to unmarshal metadata: %w", err) - } - } - } - - // Fallback to function metadata from upstash - if len(metadata.EntrypointPath) == 0 { - upstash, err := getFunctionMetadata(ctx, projectRef, slug) - if err != nil { - return errors.Errorf("failed to get function metadata: %w", err) - } - entrypointUrl, err := url.Parse(*upstash.EntrypointPath) - if err != nil { - return errors.Errorf("failed to parse entrypoint URL: %w", err) - } - metadata.EntrypointPath = entrypointUrl.Path - } - fmt.Fprintln(utils.GetDebugLogger(), "Using entrypoint path:", metadata.EntrypointPath) - - // Root directory on disk: supabase/functions/ - funcDir := filepath.Join(utils.FunctionsDir, slug) - for _, data := range form.File { - for _, file := range data { - if err := saveFile(file, metadata.EntrypointPath, funcDir, fsys); err != nil { - return err - } - } - } - - fmt.Fprintf(os.Stderr, "Downloaded Function %s from project %s.\n", utils.Aqua(slug), utils.Aqua(projectRef)) - return nil -} - -func readForm(ctx context.Context, projectRef, slug string) (*multipart.Form, error) { - // Request multipart/form-data response using RequestEditorFn - resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error { - req.Header.Set("Accept", "multipart/form-data") - return nil - }) - if err != nil { - return nil, errors.Errorf("failed to download function: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Errorf("Error status %d: %w", resp.StatusCode, err) - } - return nil, errors.Errorf("Error status %d: %s", resp.StatusCode, string(body)) - } - - // Parse the multipart response - mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) - if err != nil { - return nil, errors.Errorf("failed to parse content type: %w", err) - } - if !strings.HasPrefix(mediaType, "multipart/") { - return nil, errors.Errorf("expected multipart response, got %s", mediaType) - } - - // Read entire response with caching to disk - mr := multipart.NewReader(resp.Body, params["boundary"]) - form, err := mr.ReadForm(units.MiB) - if err != nil { - return nil, errors.Errorf("failed to read form: %w", err) - } - - return form, nil -} - -func saveFile(file *multipart.FileHeader, entrypointPath, funcDir string, fsys afero.Fs) error { - part, err := file.Open() - if err != nil { - return errors.Errorf("failed to open file: %w", err) - } - defer part.Close() - - logger := utils.GetDebugLogger() - partPath, err := getPartPath(file.Header) - if len(partPath) == 0 { - fmt.Fprintln(logger, "Skipping file with empty path:", file.Filename) - return err - } - fmt.Fprintln(logger, "Resolving file path:", partPath) - - relPath, err := filepath.Rel(filepath.FromSlash(entrypointPath), filepath.FromSlash(partPath)) - if err != nil { - // Continue extracting without entrypoint. Go's Rel error embeds - // both paths verbatim ("Rel: can't make relative to - // "), and partPath is server-controlled, so this is gated - // behind --debug like the "Resolving file path" log above rather - // than printed unconditionally to stderr. - fmt.Fprintln(logger, "WARNING:", err) - relPath = filepath.FromSlash(path.Join("..", partPath)) - } - - // partPath (and therefore relPath and dstPath) is derived from - // server-controlled multipart metadata (Supabase-Path header or - // Content-Disposition filename), so it must be validated before it - // touches the filesystem below. - dstPath := filepath.Join(funcDir, path.Base(entrypointPath), relPath) - - // Containment is enforced against the shared functions directory - // rather than funcDir: the entrypoint-mismatch fallback above can - // legitimately resolve one level above funcDir (into a sibling - // function's directory), but must never resolve outside - // utils.FunctionsDir entirely, e.g. via a "../../../etc/passwd" style - // payload. - root := utils.FunctionsDir - if err := validateDownloadPath(root, dstPath); err != nil { - utils.CmdSuggestion = suggestUnsafeDownloadPath() - return err - } - - // A previous part could have planted a symlink at an intermediate - // directory component, which would otherwise let MkdirAll/OpenFile - // silently follow it out of root even though dstPath itself looks - // clean. Check before creating the directory, and again after the - // write lands in case a symlink was swapped in between the two checks. - dstDir := filepath.Dir(dstPath) - if err := ensureNoSymlinkInPath(fsys, root, dstDir); err != nil { - utils.CmdSuggestion = suggestUnsafeDownloadPath() - return err - } - if err := utils.MkdirIfNotExistFS(fsys, dstDir); err != nil { - return err - } - - fmt.Fprintln(os.Stderr, "Extracting file:", dstPath) - if err := writeFileNoFollowSymlink(fsys, dstPath, part); err != nil { - return err - } - - if err := ensureNoSymlinkInPath(fsys, root, dstPath); err != nil { - utils.CmdSuggestion = suggestUnsafeDownloadPath() - return err - } - return nil -} - -// validateDownloadPath rejects dstPath if, once lexically cleaned, it does -// not resolve inside root. This is the primary defense against a hostile -// "../../etc/passwd" style Supabase-Path/filename escaping the functions -// directory: filepath.Join already cleans ".." segments away, so this -// check must run against the cleaned result rather than by scanning the -// raw, attacker-controlled path for "..". -func validateDownloadPath(root, dstPath string) error { - rel, err := filepath.Rel(root, filepath.Clean(dstPath)) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { - return errors.Errorf("failed to save file: %w", ErrUnsafeDownloadPath) - } - return nil -} - -// ensureNoSymlinkInPath rejects dir if resolving the symlinks along it would -// land outside root. -// -// Earlier versions of this check rejected the mere presence of a symlink -// anywhere under root, which also broke legitimate setups such as a -// monorepo symlinking a shared directory into place inside the functions -// tree. Instead, this resolves both root and dir the same way -- walking up -// to the deepest ancestor that already exists on disk, following any chain -// of symlinks there via filepath.EvalSymlinks (this also naturally covers -// root itself being a symlink, since dir's walk passes through root), and -// re-joining the still-nonexistent remainder back on -- and re-validates -// the two resolved paths against each other with validateDownloadPath. -// Resolving root the same way dir is resolved, rather than comparing a -// resolved dir against a literal root, matters in practice: it is what -// keeps an OS-level symlink that sits above both of them (e.g. macOS's -// /var -> /private/var, which every path under a t.TempDir() passes -// through) from producing a spurious mismatch. Only a resolved dir that -// escapes the resolved root is rejected; a symlink whose target still -// lands inside root, however deeply nested, is now allowed. -// -// This only has an effect on filesystems that expose real symlink -// semantics through afero.LinkReader, i.e. afero.NewOsFs in production -// (afero.Lstater is not a reliable enough signal on its own: afero.MemMapFs -// also implements it, just by delegating straight to Stat). The in-memory -// filesystem used in tests implements neither, so this check is -// effectively a no-op there -- there is nothing to protect against on a -// filesystem that cannot contain symlinks in the first place. That same -// guard is also why it is safe for filepath.EvalSymlinks below to hit the -// real OS filesystem directly instead of going through fsys: the only -// afero.Fs implementation used in production, afero.NewOsFs, delegates to -// the real OS filesystem for every operation, so the two agree. -func ensureNoSymlinkInPath(fsys afero.Fs, root, dir string) error { - if _, ok := fsys.(afero.LinkReader); !ok { - return nil - } - - // filepath.EvalSymlinks returns an absolute result as soon as it - // crosses one absolute symlink, but stays relative otherwise (per its - // doc comment), so root and dir must both start out absolute here -- - // otherwise root (no symlink crossed) and dir (crossing one) could - // resolve to a relative and an absolute path respectively, which - // filepath.Rel below cannot meaningfully compare. - absRoot, err := filepath.Abs(root) - if err != nil { - return errors.Errorf("failed to inspect extraction path: %w", err) - } - absDir, err := filepath.Abs(dir) - if err != nil { - return errors.Errorf("failed to inspect extraction path: %w", err) - } - - resolvedRoot, err := resolveExistingPath(fsys, absRoot) - if err != nil { - return errors.Errorf("failed to inspect extraction path: %w", err) - } - resolvedDir, err := resolveExistingPath(fsys, absDir) - if err != nil { - return errors.Errorf("failed to inspect extraction path: %w", err) - } - - return validateDownloadPath(resolvedRoot, resolvedDir) -} - -// resolveExistingPath resolves p by walking up to the deepest ancestor of it -// that already exists -- a path component that does not exist yet cannot -// itself be a symlink, so there is nothing there for EvalSymlinks to -// resolve -- following any chain of symlinks in that ancestor to its real -// target, then re-joining the still-nonexistent remainder of p back onto -// the result. -func resolveExistingPath(fsys afero.Fs, p string) (string, error) { - existing := p - var suffix []string - for { - if _, err := fsys.Stat(existing); err == nil { - break - } else if !os.IsNotExist(err) { - return "", err - } - parent := filepath.Dir(existing) - if parent == existing { - // Reached the filesystem root without finding anything that - // exists yet, so there is nothing left to resolve. - return filepath.Join(append([]string{existing}, suffix...)...), nil - } - suffix = append([]string{filepath.Base(existing)}, suffix...) - existing = parent - } - - resolved, err := filepath.EvalSymlinks(existing) - if err != nil { - return "", err - } - return filepath.Join(append([]string{resolved}, suffix...)...), nil -} - -// writeFileNoFollowSymlink writes r to dstPath without following a symlink -// that might already occupy that path. It creates a randomly named -// temporary file next to dstPath with O_EXCL, refusing to write through -// anything already there, then atomically renames it onto dstPath. -// Rename replaces whatever directory entry currently exists at dstPath -- -// including a symlink -- rather than dereferencing it, which is exactly -// what a plain fs.Create/afero.WriteReader would otherwise do. -func writeFileNoFollowSymlink(fsys afero.Fs, dstPath string, r io.Reader) error { - tmpPath := filepath.Join(filepath.Dir(dstPath), fmt.Sprintf(".supabase-download-%s.tmp", uuid.NewString())) - tmp, err := fsys.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - return errors.Errorf("failed to save file: %w", err) - } - - if _, err := io.Copy(tmp, r); err != nil { - _ = tmp.Close() - _ = fsys.Remove(tmpPath) - return errors.Errorf("failed to save file: %w", err) - } - if err := tmp.Close(); err != nil { - _ = fsys.Remove(tmpPath) - return errors.Errorf("failed to save file: %w", err) - } - - if err := fsys.Rename(tmpPath, dstPath); err != nil { - _ = fsys.Remove(tmpPath) - return errors.Errorf("failed to save file: %w", err) - } - return nil -} - -// getPartPath extracts the filename for a multipart part, allowing for -// relative paths via the custom Supabase-Path header. -func getPartPath(header textproto.MIMEHeader) (string, error) { - // dedicated header to specify relative path, not expected to be used - if relPath := header.Get("Supabase-Path"); relPath != "" { - return relPath, nil - } - - // part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually - cd := header.Get("Content-Disposition") - if cd == "" { - return "", nil - } - - _, params, err := mime.ParseMediaType(cd) - if err != nil { - return "", errors.Errorf("failed to parse content disposition: %w", err) - } - - if filename := params["filename"]; filename != "" { - return filename, nil - } - return "", nil -} diff --git a/apps/cli-go/internal/functions/download/download_test.go b/apps/cli-go/internal/functions/download/download_test.go deleted file mode 100644 index 8267dc7eeb..0000000000 --- a/apps/cli-go/internal/functions/download/download_test.go +++ /dev/null @@ -1,914 +0,0 @@ -package download - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "mime/multipart" - "net/http" - "net/textproto" - "os" - "path" - "path/filepath" - "strings" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/api/types/volume" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" -) - -func TestMain(m *testing.M) { - // Setup fake deno binary - if len(os.Args) > 1 && (os.Args[1] == "bundle" || os.Args[1] == "upgrade" || os.Args[1] == "run") { - msg := os.Getenv("TEST_DENO_ERROR") - if msg != "" { - fmt.Fprintln(os.Stderr, msg) - os.Exit(1) - } - os.Exit(0) - } - denoPath, err := os.Executable() - if err != nil { - log.Fatalln(err) - } - utils.DenoPathOverride = denoPath - // Run test suite - os.Exit(m.Run()) -} - -type multipartPart struct { - filename string - supabasePath string - contents string -} - -func mockMultipartBody(t *testing.T, projectRef, slug string, metadata bundleMetadata, parts []multipartPart) { - t.Helper() - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - // Write metadata - headers := textproto.MIMEHeader{} - headers.Set("Content-Disposition", `form-data; name="metadata"`) - headers.Set("Content-Type", "application/json") - pw, err := writer.CreatePart(headers) - require.NoError(t, err) - enc := json.NewEncoder(pw) - require.NoError(t, enc.Encode(metadata)) - // Write files - for _, part := range parts { - headers := textproto.MIMEHeader{} - headers.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, part.filename)) - if part.supabasePath != "" { - headers.Set("Supabase-Path", part.supabasePath) - } - pw, err := writer.CreatePart(headers) - require.NoError(t, err) - _, err = pw.Write([]byte(part.contents)) - require.NoError(t, err) - } - require.NoError(t, writer.Close()) - - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s/body", projectRef, slug)). - Reply(http.StatusOK). - SetHeader("Content-Type", writer.FormDataContentType()). - Body(&buf) -} - -func TestRunLegacyUnbundle(t *testing.T) { - const slug = "test-func" - - t.Run("downloads eszip bundle", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup valid project ref - project := apitest.RandomProjectRef() - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - // Setup valid deno path - _, err := fsys.Create(utils.DenoPathOverride) - require.NoError(t, err) - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Id: "1"}) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug + "/body"). - Reply(http.StatusOK) - // Run test - err = Run(context.Background(), slug, project, true, false, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on malformed slug", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup valid project ref - project := apitest.RandomProjectRef() - // Run test - err := Run(context.Background(), "@", project, true, false, fsys) - // Check error - assert.ErrorContains(t, err, "Invalid Function name.") - }) - - t.Run("throws error on failure to install deno", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Setup valid project ref - project := apitest.RandomProjectRef() - // Run test - err := Run(context.Background(), slug, project, true, false, fsys) - // Check error - assert.ErrorContains(t, err, "operation not permitted") - }) - - t.Run("throws error on copy failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup valid project ref - project := apitest.RandomProjectRef() - // Setup valid deno path - _, err := fsys.Create(utils.DenoPathOverride) - require.NoError(t, err) - // Run test - err = Run(context.Background(), slug, project, true, false, afero.NewReadOnlyFs(fsys)) - // Check error - assert.ErrorContains(t, err, "operation not permitted") - }) - - t.Run("throws error on missing function", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup valid project ref - project := apitest.RandomProjectRef() - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - // Setup valid deno path - _, err := fsys.Create(utils.DenoPathOverride) - require.NoError(t, err) - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusNotFound). - JSON(map[string]string{"message": "Function not found"}) - // Run test - err = Run(context.Background(), slug, project, true, false, fsys) - // Check error - assert.ErrorContains(t, err, "Function test-func does not exist on the Supabase project.") - }) -} - -func TestRunDockerUnbundle(t *testing.T) { - t.Run("downloads bundle with docker when available", func(t *testing.T) { - const slugDocker = "demo" - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - project := apitest.RandomProjectRef() - require.NoError(t, flags.LoadConfig(fsys)) - - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - require.NoError(t, apitest.MockDocker(utils.Docker)) - dockerHost := utils.Docker.DaemonHost() - - // Setup mock api - defer gock.OffAll() - - gock.New(dockerHost). - Head("/_ping"). - Reply(http.StatusOK) - - imageURL := utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image) - containerID := "docker-unbundle-test" - var createRequest struct { - Cmd []string `json:"Cmd"` - HostConfig struct { - Binds []string `json:"Binds"` - } `json:"HostConfig"` - } - gock.New(dockerHost). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + imageURL + "/json"). - Reply(http.StatusOK). - JSON(image.InspectResponse{}) - gock.New(dockerHost). - Post("/v" + utils.Docker.ClientVersion() + "/networks/create"). - Reply(http.StatusCreated). - JSON(network.CreateResponse{}) - gock.New(dockerHost). - Post("/v" + utils.Docker.ClientVersion() + "/volumes/create"). - Persist(). - Reply(http.StatusCreated). - JSON(volume.Volume{}) - gock.New(dockerHost). - Post("/v" + utils.Docker.ClientVersion() + "/containers/create"). - AddMatcher(func(req *http.Request, ereq *gock.Request) (bool, error) { - body, err := io.ReadAll(req.Body) - if err != nil { - return false, err - } - return true, json.Unmarshal(body, &createRequest) - }). - Reply(http.StatusOK). - JSON(container.CreateResponse{ID: containerID}) - gock.New(dockerHost). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + containerID + "/start"). - Reply(http.StatusAccepted) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerID, "unbundle ok")) - - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s/body", project, slugDocker)). - Reply(http.StatusOK). - BodyString("fake eszip payload") - - err := Run(context.Background(), slugDocker, project, false, true, fsys) - require.NoError(t, err) - - eszipPath := filepath.Join(utils.TempDir, fmt.Sprintf("output_%s.eszip", slugDocker)) - exists, err := afero.Exists(fsys, eszipPath) - require.NoError(t, err) - assert.False(t, exists, "temporary eszip file should be removed after extraction") - - hostFunctionsDirPath, err := filepath.Abs(utils.FunctionsDir) - require.NoError(t, err) - hostEszipPath, err := filepath.Abs(eszipPath) - require.NoError(t, err) - assert.EqualValues(t, []string{ - "unbundle", - "--eszip", - path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath)), - "--output", - path.Join(utils.DockerDenoDir, slugDocker), - }, createRequest.Cmd) - assert.Contains(t, createRequest.HostConfig.Binds, utils.EdgeRuntimeId+":/root/.cache/deno:rw") - assert.Contains(t, createRequest.HostConfig.Binds, hostEszipPath+":"+path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath))+":ro") - assert.Contains(t, createRequest.HostConfig.Binds, hostFunctionsDirPath+":"+utils.DockerDenoDir+":rw") - for _, bind := range createRequest.HostConfig.Binds { - assert.False(t, strings.Contains(bind, filepath.Join(hostFunctionsDirPath, slugDocker)+":"+utils.DockerDenoDir), - "docker output should mount supabase/functions, not the slug directory") - } - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("falls back to server-side unbundle when docker unavailable", func(t *testing.T) { - const slugDocker = "demo-fallback" - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - project := apitest.RandomProjectRef() - require.NoError(t, flags.LoadConfig(fsys)) - - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - require.NoError(t, apitest.MockDocker(utils.Docker)) - dockerHost := utils.Docker.DaemonHost() - - // Setup mock api - defer gock.OffAll() - - gock.New(dockerHost). - Head("/_ping"). - ReplyError(errors.New("docker unavailable")) - - mockMultipartBody(t, project, slugDocker, bundleMetadata{"/source/index.ts"}, []multipartPart{ - {filename: "/source/index.ts", contents: "console.log('hello')"}, - }) - - err := Run(context.Background(), slugDocker, project, false, true, fsys) - require.NoError(t, err) - - data, err := afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, slugDocker, "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('hello')", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -// TestDownloadAllRejectsMaliciousSlug is a regression test for CLI-1891: the -// per-function loop in downloadAll must reject a path-traversal payload in -// a function's Slug -- as returned by V1ListAllFunctionsWithResponse, which -// this threat model treats as untrusted (a malicious/compromised Management -// API response, or a MITM) -- before handing it to any downloader that -// joins it into a filesystem path. -// -// This mirrors an exploit independently confirmed against downloadOne: with -// slug = "../../../../../poc-escaped-outside-project", downloadOne's -// eszipPath := filepath.Join(utils.TempDir, fmt.Sprintf("output_%s.eszip", -// slug)) resolves (after filepath.Clean) to "../poc-escaped-outside-project.eszip", -// i.e. one directory level above the project root, and -// afero.WriteReader happily MkdirAll's its way there and writes the -// server-controlled response body outside the sandbox. -func TestDownloadAllRejectsMaliciousSlug(t *testing.T) { - const maliciousSlug = "../../../../../poc-escaped-outside-project" - - // Use a real OS filesystem rooted at an isolated temp directory so an - // escape can actually be observed landing outside the project root, - // exactly as in the reviewer's PoC. - tmpDir := t.TempDir() - t.Chdir(tmpDir) - fsys := afero.NewOsFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - project := apitest.RandomProjectRef() - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - utils.CmdSuggestion = "" - t.Cleanup(func() { utils.CmdSuggestion = "" }) - - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{{ - Id: "poc-id", - Name: "poc", - Slug: maliciousSlug, - }}) - // Mocked so that, if the fix is removed, the malicious slug's request - // still succeeds and downloadOne proceeds all the way to writing the - // escaped file -- proving the escape, rather than masking it behind an - // unrelated network error. With the fix in place this mock is never hit. - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/.*/body"). - Reply(http.StatusOK). - BodyString("fake eszip payload") - - // downloadOne is the exact sink the reviewer's PoC targeted directly; - // wrap it as a downloader so downloadAll's dispatch is exercised against - // the real vulnerable code, without also pulling in downloadWithDockerUnbundle's - // unrelated Docker extraction step (and its defer-cleanup of the eszip - // file, which would otherwise remove the escaped file before this test - // can observe it). - downloaderCalled := false - downloader := func(ctx context.Context, slug, projectRef string, fsys afero.Fs) error { - downloaderCalled = true - _, err := downloadOne(ctx, slug, projectRef, fsys) - return err - } - - err := downloadAll(context.Background(), project, fsys, downloader) - - // Check for the escape before any assertion below that could halt the - // test on failure (e.g. require.Error), so this is verified regardless - // of whether downloadAll happened to return an error for some other - // reason. t.TempDir()'s own cleanup RemoveAll's tmpDir's parent, which - // would otherwise sweep away the evidence once the test returns. - // - // The exploit resolves to "../poc-escaped-outside-project.eszip" - // relative to utils.TempDir, i.e. one level above the project root. - escapedPath := filepath.Join(tmpDir, "..", "poc-escaped-outside-project.eszip") - t.Cleanup(func() { _ = os.Remove(escapedPath) }) - exists, existsErr := afero.Exists(fsys, escapedPath) - require.NoError(t, existsErr) - assert.False(t, exists, "malicious slug must not be able to write outside the project directory") - - require.Error(t, err) - assert.ErrorIs(t, err, utils.ErrInvalidSlug) - assert.Contains(t, utils.CmdSuggestion, "unexpected function slug") - assert.False(t, downloaderCalled, "downloader must not be invoked with an unvalidated slug") - - assert.Empty(t, apitest.ListUnmatchedRequests()) -} - -func TestRunServerSideUnbundle(t *testing.T) { - const slug = "test-func" - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - project := apitest.RandomProjectRef() - - t.Run("writes files using inferred base directory", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "source/utils.ts", contents: "export const value = 1;"}, - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - require.NoError(t, err) - - data, err := afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, slug, "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('hello')", string(data)) - - data, err = afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, slug, "utils.ts")) - require.NoError(t, err) - assert.Equal(t, "export const value = 1;", string(data)) - - entries, err := afero.ReadDir(fsys, utils.TempDir) - if err == nil { - assert.Len(t, entries, 0, "expected temporary directory to be cleaned up") - } else { - assert.ErrorIs(t, err, os.ErrNotExist) - } - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("derives base directory from absolute filenames", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - defer gock.OffAll() - indexPath := "/tmp/functions-download-abs/source/index.ts" - utilsPath := path.Join(path.Dir(indexPath), "lib", "utils.ts") - mockMultipartBody(t, project, slug, bundleMetadata{}, []multipartPart{ - {filename: indexPath, contents: "console.log('abs')"}, - {filename: utilsPath, contents: "export const util = 2;"}, - }) - - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s", project, slug)). - Reply(http.StatusOK). - JSON(api.FunctionSlugResponseOutput{ - Id: "1", - Name: slug, - Slug: slug, - Status: api.FunctionSlugResponseOutputStatus("ACTIVE"), - Version: 1, - CreatedAt: 0, - UpdatedAt: 0, - EntrypointPath: cast.Ptr("file://" + indexPath), - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - require.NoError(t, err) - - root := filepath.Join(utils.FunctionsDir, slug) - data, err := afero.ReadFile(fsys, filepath.Join(root, "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('abs')", string(data)) - - data, err = afero.ReadFile(fsys, filepath.Join(root, "lib", "utils.ts")) - require.NoError(t, err) - assert.Equal(t, "export const util = 2;", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("fails when response not multipart", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s", project, slug)). - Reply(http.StatusOK). - JSON(api.FunctionSlugResponseOutput{ - Id: "1", - Name: slug, - Slug: slug, - Status: api.FunctionSlugResponseOutputStatus("ACTIVE"), - Version: 1, - CreatedAt: 0, - UpdatedAt: 0, - EntrypointPath: cast.Ptr(legacyEntrypointPath), - }) - - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s/body", project, slug)). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/json"). - BodyString(`{"error":"no multipart"}`) - - err := Run(context.Background(), slug, project, false, false, fsys) - assert.ErrorContains(t, err, "expected multipart response") - }) - - t.Run("ignores unresolvable entrypoint path", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "source/secret.env", supabasePath: "../secret.env", contents: "SECRET=1"}, - }) - - gock.New(utils.DefaultApiHost). - Get(fmt.Sprintf("/v1/projects/%s/functions/%s", project, slug)). - Reply(http.StatusOK). - JSON(api.FunctionSlugResponseOutput{ - Id: "1", - Name: slug, - Slug: slug, - Status: api.FunctionSlugResponseOutputStatus("ACTIVE"), - Version: 1, - CreatedAt: 0, - UpdatedAt: 0, - EntrypointPath: cast.Ptr("file:///source/index.ts"), - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - assert.NoError(t, err) - - root := filepath.Join(utils.FunctionsDir, slug) - data, err := afero.ReadFile(fsys, filepath.Join(root, "source", "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('hello')", string(data)) - - data, err = afero.ReadFile(fsys, filepath.Join(utils.FunctionsDir, "secret.env")) - require.NoError(t, err) - assert.Equal(t, "SECRET=1", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("rejects a path traversal payload in Supabase-Path", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - utils.CmdSuggestion = "" - t.Cleanup(func() { utils.CmdSuggestion = "" }) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "leftover.ts", supabasePath: "../../../../../../etc/passwd", contents: "root:x:0:0::/root:/bin/bash"}, - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnsafeDownloadPath) - - // The generic "invalid path in server response" message on its own - // gives users nothing actionable, so this must carry a specific - // suggestion (DX finding on CLI-1891) rather than falling through - // to the generic --debug suggestion. - assert.Contains(t, utils.CmdSuggestion, "malformed or unexpected API response") - - // Nothing should have escaped the functions directory. - exists, err := afero.Exists(fsys, "/etc/passwd") - require.NoError(t, err) - assert.False(t, exists, "path traversal payload must not be written outside the functions directory") - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("writes deeply nested legitimate paths", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "source/a/b/c/d/deep.ts", contents: "export const deep = true;"}, - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - require.NoError(t, err) - - root := filepath.Join(utils.FunctionsDir, slug) - data, err := afero.ReadFile(fsys, filepath.Join(root, "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('hello')", string(data)) - - data, err = afero.ReadFile(fsys, filepath.Join(root, "a", "b", "c", "d", "deep.ts")) - require.NoError(t, err) - assert.Equal(t, "export const deep = true;", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("does not write through a pre-existing symlink at the destination", func(t *testing.T) { - // Symlink semantics only exist on a real filesystem, so this test - // exercises afero.NewOsFs against an isolated temp directory rather - // than the in-memory fs used elsewhere in this file. - tmpDir := t.TempDir() - t.Chdir(tmpDir) - fsys := afero.NewOsFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - // A file living outside the sandboxed functions directory that must - // never be touched by the download. - outsideDir := filepath.Join(tmpDir, "outside") - require.NoError(t, os.MkdirAll(outsideDir, 0o755)) - secretPath := filepath.Join(outsideDir, "secret.txt") - require.NoError(t, os.WriteFile(secretPath, []byte("original"), 0o600)) - - // Plant a symlink inside the function directory, before the - // download runs, pointing at the file outside the sandbox. A - // server response that resolves to this exact path must not be - // allowed to write through it. - funcDir := filepath.Join(utils.FunctionsDir, slug) - require.NoError(t, os.MkdirAll(funcDir, 0o755)) - linkPath := filepath.Join(funcDir, "evil.ts") - require.NoError(t, os.Symlink(secretPath, linkPath)) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "source/evil.ts", contents: "overwritten"}, - }) - - err := Run(context.Background(), slug, project, false, false, fsys) - require.NoError(t, err) - - // The symlink target outside the sandbox must be untouched. - data, err := os.ReadFile(secretPath) - require.NoError(t, err) - assert.Equal(t, "original", string(data), "file outside the functions directory must not be modified") - - // The destination itself should now be a plain file with the - // downloaded contents: the atomic rename replaces the symlink - // directory entry rather than following it. - info, err := os.Lstat(linkPath) - require.NoError(t, err) - assert.Zero(t, info.Mode()&os.ModeSymlink, "planted symlink should have been replaced, not written through") - - data, err = os.ReadFile(linkPath) - require.NoError(t, err) - assert.Equal(t, "overwritten", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - // Regression test for the DX finding on CLI-1891: ensureNoSymlinkInPath - // used to reject the mere presence of a symlink anywhere under - // utils.FunctionsDir, which would also have broken a legitimate - // monorepo pattern like this one, where a function directory - // symlinks in a shared directory that itself lives inside the - // functions tree. That symlink's target still resolves inside root, - // so it must be allowed end-to-end. - t.Run("writes through a symlink pointing to a legitimate location inside the functions directory", func(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - fsys := afero.NewOsFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - - sharedDir := filepath.Join(utils.FunctionsDir, "_shared") - require.NoError(t, os.MkdirAll(sharedDir, 0o755)) - // os.Symlink resolves a relative target against the symlink's own - // containing directory, not the process cwd, so the target must be - // made absolute here for the link to actually point at sharedDir. - absSharedDir, err := filepath.Abs(sharedDir) - require.NoError(t, err) - - funcDir := filepath.Join(utils.FunctionsDir, slug) - require.NoError(t, os.MkdirAll(funcDir, 0o755)) - require.NoError(t, os.Symlink(absSharedDir, filepath.Join(funcDir, "_shared"))) - - defer gock.OffAll() - mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ - {filename: "source/index.ts", contents: "console.log('hello')"}, - {filename: "source/_shared/util.ts", contents: "export const util = 2;"}, - }) - - err = Run(context.Background(), slug, project, false, false, fsys) - require.NoError(t, err) - - data, err := afero.ReadFile(fsys, filepath.Join(funcDir, "index.ts")) - require.NoError(t, err) - assert.Equal(t, "console.log('hello')", string(data)) - - // The write landed through the symlink, in the real shared - // directory rather than being rejected. - data, err = os.ReadFile(filepath.Join(sharedDir, "util.ts")) - require.NoError(t, err) - assert.Equal(t, "export const util = 2;", string(data)) - - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -// TestEnsureNoSymlinkInPath exercises ensureNoSymlinkInPath's resolve-then-check -// policy directly: it must still reject a symlink whose target escapes root, -// but must now allow one whose target resolves to a legitimate, even deeply -// nested, location that is still inside root. -func TestEnsureNoSymlinkInPath(t *testing.T) { - t.Run("rejects a symlink whose target resolves outside root", func(t *testing.T) { - tmpDir := t.TempDir() - fsys := afero.NewOsFs() - - root := filepath.Join(tmpDir, "supabase", "functions") - require.NoError(t, os.MkdirAll(root, 0o755)) - - outsideDir := filepath.Join(tmpDir, "outside") - require.NoError(t, os.MkdirAll(outsideDir, 0o755)) - - // Plant a symlink inside root whose target lives outside it - // entirely -- the actual attack this check defends against. - linkDir := filepath.Join(root, "escape") - require.NoError(t, os.Symlink(outsideDir, linkDir)) - - dir := filepath.Join(linkDir, "nested") - err := ensureNoSymlinkInPath(fsys, root, dir) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnsafeDownloadPath) - }) - - t.Run("allows a symlink whose target resolves to a nested location inside root", func(t *testing.T) { - tmpDir := t.TempDir() - fsys := afero.NewOsFs() - - root := filepath.Join(tmpDir, "supabase", "functions") - require.NoError(t, os.MkdirAll(root, 0o755)) - - // A legitimate monorepo-style symlink: a shared directory that - // lives elsewhere inside the functions tree, symlinked into place - // from a function's own directory. - sharedDir := filepath.Join(root, "_shared") - require.NoError(t, os.MkdirAll(sharedDir, 0o755)) - - funcDir := filepath.Join(root, "my-func") - require.NoError(t, os.MkdirAll(funcDir, 0o755)) - linkDir := filepath.Join(funcDir, "_shared") - require.NoError(t, os.Symlink(sharedDir, linkDir)) - - // dir itself does not exist yet -- ensureNoSymlinkInPath is called - // before MkdirIfNotExistFS creates it -- so this also proves the - // still-nonexistent remainder is correctly re-joined onto the - // resolved ancestor. - dir := filepath.Join(linkDir, "nested", "deeper") - err := ensureNoSymlinkInPath(fsys, root, dir) - assert.NoError(t, err, "a symlink resolving to a legitimate location inside root must be allowed") - }) -} - -func TestDownloadFunction(t *testing.T) { - const slug = "test-func" - // Setup valid project ref - project := apitest.RandomProjectRef() - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - t.Run("throws error on network error", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Id: "1"}) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug + "/body"). - ReplyError(errors.New("network error")) - // Run test - err := downloadFunction(context.Background(), project, slug, "") - // Check error - assert.ErrorContains(t, err, "network error") - }) - - t.Run("throws error on service unavailable", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Id: "1"}) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug + "/body"). - Reply(http.StatusServiceUnavailable) - // Run test - err := downloadFunction(context.Background(), project, slug, "") - // Check error - assert.ErrorContains(t, err, "Unexpected error downloading Function:") - }) - - t.Run("throws error on extract failure", func(t *testing.T) { - // Setup deno error - t.Setenv("TEST_DENO_ERROR", "extract failed") - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Id: "1"}) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug + "/body"). - Reply(http.StatusOK) - // Run test - err := downloadFunction(context.Background(), project, slug, "") - // Check error - assert.ErrorContains(t, err, "Error downloading function: exit status 1\nextract failed\n") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestGetMetadata(t *testing.T) { - const slug = "test-func" - project := apitest.RandomProjectRef() - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - t.Run("fallback to default paths", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Id: "1"}) - // Run test - meta, err := getFunctionMetadata(context.Background(), project, slug) - // Check error - assert.NoError(t, err) - assert.Equal(t, legacyEntrypointPath, *meta.EntrypointPath) - assert.Equal(t, legacyImportMapPath, *meta.ImportMapPath) - }) - - t.Run("throws error on network error", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - ReplyError(errors.New("network error")) - // Run test - meta, err := getFunctionMetadata(context.Background(), project, slug) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Nil(t, meta) - }) - - t.Run("throws error on service unavailable", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + project + "/functions/" + slug). - Reply(http.StatusServiceUnavailable) - // Run test - meta, err := getFunctionMetadata(context.Background(), project, slug) - // Check error - assert.ErrorContains(t, err, "Failed to download Function test-func on the Supabase project:") - assert.Nil(t, meta) - }) -} - -func TestGetPartPath(t *testing.T) { - t.Parallel() - - t.Run("returns path from Supabase header", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Supabase-Path", "dir/file.ts") - got, err := getPartPath(header) - require.NoError(t, err) - assert.Equal(t, "dir/file.ts", got) - }) - - t.Run("returns filename from content disposition", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Content-Disposition", `form-data; name="file"; filename="test-func/index.ts"`) - got, err := getPartPath(header) - require.NoError(t, err) - assert.Equal(t, "test-func/index.ts", got) - }) - - t.Run("returns filename from editor-originated content disposition", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Content-Disposition", `form-data; name="file"; filename="source/index.ts"`) - got, err := getPartPath(header) - require.NoError(t, err) - assert.Equal(t, "source/index.ts", got) - }) - - t.Run("writes file of arbitrary depth", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Content-Disposition", `form-data; name="file"; filename="test-func/dir/subdir/file.ts"`) - got, err := getPartPath(header) - require.NoError(t, err) - assert.Equal(t, "test-func/dir/subdir/file.ts", got) - }) - - t.Run("returns empty when no filename provided", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Content-Disposition", `form-data; name="file"`) - got, err := getPartPath(header) - require.NoError(t, err) - assert.Equal(t, "", got) - }) - - t.Run("returns error on invalid content disposition", func(t *testing.T) { - header := textproto.MIMEHeader{} - header.Set("Content-Disposition", `form-data; filename="unterminated`) - got, err := getPartPath(header) - require.ErrorContains(t, err, "failed to parse content disposition") - assert.Equal(t, "", got) - }) -} diff --git a/apps/cli-go/internal/gen/types/templates/prod-ca-2021.crt b/apps/cli-go/internal/gen/types/templates/prod-ca-2021.crt deleted file mode 100644 index 3d693669b2..0000000000 --- a/apps/cli-go/internal/gen/types/templates/prod-ca-2021.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL -BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh -c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow -azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD -YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug -Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW -QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q -DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2 -GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi -cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4 -O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt -NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX -uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt -aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU -tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b -VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6 -jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx -Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2 -CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P -o/bKiIz+Fq8= ------END CERTIFICATE----- diff --git a/apps/cli-go/internal/gen/types/templates/prod-ca-2025.crt b/apps/cli-go/internal/gen/types/templates/prod-ca-2025.crt deleted file mode 100644 index da35f1f84a..0000000000 --- a/apps/cli-go/internal/gen/types/templates/prod-ca-2025.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL -BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh -c2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow -azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD -YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug -Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7 -i9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm -LXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3 -1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z -RwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA -fSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt -C40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB -YOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP -XKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx -z9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc -FhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h -4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV -c22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S -O19yVSxxCIf64Sg= ------END CERTIFICATE----- diff --git a/apps/cli-go/internal/gen/types/templates/staging-ca-2021.crt b/apps/cli-go/internal/gen/types/templates/staging-ca-2021.crt deleted file mode 100644 index 925f9f5af0..0000000000 --- a/apps/cli-go/internal/gen/types/templates/staging-ca-2021.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL -BQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh -c2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2 -MTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE -BwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T -dXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg -N2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe -YD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5 -PAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG -rXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ -aZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD -AgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF -x+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB -CwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG -OvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk -OgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K -25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J -vIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq -Nzbc6lnA69urKffvcT07YwMsY/OmHzVa ------END CERTIFICATE----- diff --git a/apps/cli-go/internal/gen/types/types.go b/apps/cli-go/internal/gen/types/types.go deleted file mode 100644 index 688ab6302f..0000000000 --- a/apps/cli-go/internal/gen/types/types.go +++ /dev/null @@ -1,253 +0,0 @@ -package types - -import ( - "context" - _ "embed" - "fmt" - "net/url" - "os" - "runtime" - "strings" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" -) - -const ( - LangTypescript = "typescript" - LangGo = "go" - LangSwift = "swift" - LangPython = "python" -) - -const ( - SwiftPublicAccessControl = "public" - SwiftInternalAccessControl = "internal" -) - -func Run(ctx context.Context, projectId string, dbConfig pgconn.Config, lang string, schemas []string, postgrestV9Compat bool, swiftAccessControl string, queryTimeout time.Duration, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - originalURL := utils.ToPostgresURL(dbConfig) - // Add default schemas if --schema flag is not specified - if len(schemas) == 0 { - schemas = utils.RemoveDuplicates(append([]string{"public"}, utils.Config.Api.Schemas...)) - } - included := strings.Join(schemas, ",") - - if projectId != "" { - if lang != LangTypescript { - return errors.Errorf("Unable to generate %s types for selected project. Try using --db-url flag instead.", lang) - } - resp, err := utils.GetSupabase().V1GenerateTypescriptTypesWithResponse(ctx, projectId, &api.V1GenerateTypescriptTypesParams{ - IncludedSchemas: &included, - }) - if err != nil { - return errors.Errorf("failed to get typescript types: %w", err) - } - - if resp.JSON200 == nil { - return errors.New("failed to retrieve generated types: " + string(resp.Body)) - } - - fmt.Print(resp.JSON200.Types) - return nil - } - - hostConfig := container.HostConfig{} - if utils.IsLocalDatabase(dbConfig) { - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - - if strings.Contains(utils.Config.Api.Image, "v9") { - postgrestV9Compat = true - } - - // Use custom network when connecting to local database - dbConfig.Host = utils.DbAliases[0] - dbConfig.Port = 5432 - } else { - hostConfig.NetworkMode = network.NetworkHost - } - // pg-meta does not set username as the default database, ie. postgres - if len(dbConfig.Database) == 0 { - dbConfig.Database = "postgres" - } - - fmt.Fprintln(os.Stderr, "Connecting to", dbConfig.Host, dbConfig.Port) - env := []string{ - "PG_META_DB_URL=" + utils.ToPostgresURL(dbConfig), - fmt.Sprintf("PG_CONN_TIMEOUT_SECS=%.0f", queryTimeout.Seconds()), - fmt.Sprintf("PG_QUERY_TIMEOUT_SECS=%.0f", queryTimeout.Seconds()), - "PG_META_GENERATE_TYPES=" + lang, - "PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=" + included, - "PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=" + swiftAccessControl, - fmt.Sprintf("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=%v", !postgrestV9Compat), - } - if ca, err := GetRootCA(ctx, originalURL, options...); err != nil { - return err - } else if len(ca) > 0 { - env = append(env, "PG_META_DB_SSL_ROOT_CERT="+ca) - } - - return utils.DockerRunOnceWithConfig( - ctx, - container.Config{ - Image: utils.Config.Studio.PgmetaImage, - Env: env, - Cmd: []string{"node", "dist/server/server.js"}, - }, - hostConfig, - network.NetworkingConfig{}, - "", - os.Stdout, - os.Stderr, - ) -} - -var ( - //go:embed templates/staging-ca-2021.crt - caStaging string - //go:embed templates/prod-ca-2021.crt - caProd string - //go:embed templates/prod-ca-2025.crt - caSnap string -) - -func GetRootCA(ctx context.Context, dbURL string, options ...func(*pgx.ConnConfig)) (string, error) { - debugf := func(string, ...any) {} - if IsSSLDebugEnabled() { - debugf = LogSSLDebugf - } - debugf("GetRootCA start db_url=%s", redactPostgresURL(dbURL)) - debugf("env SUPABASE_CA_SKIP_VERIFY=%q SUPABASE_SSL_DEBUG=%q PGSSLROOTCERT=%q SSL_CERT_FILE=%q SSL_CERT_DIR=%q", - os.Getenv("SUPABASE_CA_SKIP_VERIFY"), - os.Getenv("SUPABASE_SSL_DEBUG"), - os.Getenv("PGSSLROOTCERT"), - os.Getenv("SSL_CERT_FILE"), - os.Getenv("SSL_CERT_DIR"), - ) - debugf("runtime goos=%s goarch=%s go=%s", runtime.GOOS, runtime.GOARCH, runtime.Version()) - // node-postgres does not support sslmode=prefer - require, err := isRequireSSL(ctx, dbURL, options...) - debugf("GetRootCA probe_result require_ssl=%t err=%v", require, err) - if !require { - return "", err - } - // Merge all certs to support --db-url flag - ca := caStaging + caProd + caSnap - debugf("GetRootCA return ca_bundle_len=%d", len(ca)) - return ca, nil -} - -func isRequireSSL(ctx context.Context, dbUrl string, options ...func(*pgx.ConnConfig)) (bool, error) { - debugf := func(string, ...any) {} - if IsSSLDebugEnabled() { - debugf = LogSSLDebugf - } - // pgx v4's sslmode=require verifies the server certificate against system CAs, - // unlike libpq where require skips verification. When SUPABASE_CA_SKIP_VERIFY=true, - // skip verification for this probe only (detects whether the server speaks TLS). - // pgconn may still install VerifyPeerCertificate callback when sslrootcert is set, - // so we also clear custom verification callbacks on all TLS configs. - // Cert validation happens downstream in the migra/pgdelta Deno scripts using GetRootCA. - opts := append([]func(*pgx.ConnConfig){}, options...) - if os.Getenv("SUPABASE_CA_SKIP_VERIFY") == "true" { - fmt.Fprintln(os.Stderr, "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)") - opts = append(opts, func(cc *pgx.ConnConfig) { - // #nosec G402 -- Intentionally skipped for this TLS capability probe only. - // Downstream migra/pgdelta flows still validate certificates using GetRootCA. - if cc.TLSConfig != nil { - cc.TLSConfig.InsecureSkipVerify = true - cc.TLSConfig.VerifyPeerCertificate = nil - cc.TLSConfig.VerifyConnection = nil - } - for _, fc := range cc.Fallbacks { - if fc.TLSConfig == nil { - continue - } - fc.TLSConfig.InsecureSkipVerify = true - fc.TLSConfig.VerifyPeerCertificate = nil - fc.TLSConfig.VerifyConnection = nil - } - }) - } - debugf("isRequireSSL probe db_url=%s skip_verify=%t", redactPostgresURL(dbUrl), os.Getenv("SUPABASE_CA_SKIP_VERIFY") == "true") - if IsSSLDebugEnabled() { - opts = append(opts, logTLSConfigState("isRequireSSL", dbUrl)) - } - conn, err := utils.ConnectByUrl(ctx, dbUrl+"&sslmode=require", opts...) - if err != nil { - debugf("isRequireSSL probe_error err=%v", err) - if strings.HasSuffix(err.Error(), "(server refused TLS connection)") { - debugf("isRequireSSL result require_ssl=false reason=server_refused_tls") - return false, nil - } - return false, err - } - debugf("isRequireSSL result require_ssl=true") - return true, conn.Close(ctx) -} - -func IsSSLDebugEnabled() bool { - return strings.EqualFold(os.Getenv("SUPABASE_SSL_DEBUG"), "true") -} - -func LogSSLDebugf(format string, args ...any) { - fmt.Fprintf(os.Stderr, "[ssl-debug] "+format+"\n", args...) -} - -func redactPostgresURL(raw string) string { - parsed, err := url.Parse(raw) - if err != nil { - return "" - } - if parsed.User != nil { - username := parsed.User.Username() - if username == "" { - parsed.User = url.UserPassword("redacted", "xxxxx") - } else { - parsed.User = url.UserPassword(username, "xxxxx") - } - } - return parsed.String() -} - -func logTLSConfigState(scope, dbUrl string) func(*pgx.ConnConfig) { - return func(cc *pgx.ConnConfig) { - if cc.TLSConfig == nil { - LogSSLDebugf("%s tls_config=nil db_url=%s fallbacks=%d", scope, redactPostgresURL(dbUrl), len(cc.Fallbacks)) - return - } - LogSSLDebugf("%s tls_config skip_verify=%t verify_peer_cb=%t verify_conn_cb=%t root_cas=%t server_name=%q fallbacks=%d", - scope, - cc.TLSConfig.InsecureSkipVerify, - cc.TLSConfig.VerifyPeerCertificate != nil, - cc.TLSConfig.VerifyConnection != nil, - cc.TLSConfig.RootCAs != nil, - cc.TLSConfig.ServerName, - len(cc.Fallbacks), - ) - for i, fc := range cc.Fallbacks { - if fc == nil || fc.TLSConfig == nil { - LogSSLDebugf("%s fallback[%d] tls_config=nil", scope, i) - continue - } - LogSSLDebugf("%s fallback[%d] skip_verify=%t verify_peer_cb=%t verify_conn_cb=%t root_cas=%t server_name=%q", - scope, - i, - fc.TLSConfig.InsecureSkipVerify, - fc.TLSConfig.VerifyPeerCertificate != nil, - fc.TLSConfig.VerifyConnection != nil, - fc.TLSConfig.RootCAs != nil, - fc.TLSConfig.ServerName, - ) - } - } -} diff --git a/apps/cli-go/internal/gen/types/types_test.go b/apps/cli-go/internal/gen/types/types_test.go deleted file mode 100644 index 915f85ad84..0000000000 --- a/apps/cli-go/internal/gen/types/types_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package types - -import ( - "context" - "errors" - "net/http" - "testing" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestGenLocalCommand(t *testing.T) { - utils.DbId = "test-db" - utils.Config.Hostname = "localhost" - utils.Config.Db.Port = 5432 - - dbConfig := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "admin", - Password: "password", - } - - t.Run("generates typescript types", func(t *testing.T) { - const containerId = "test-pgmeta" - imageUrl := utils.GetRegistryImageUrl(utils.Config.Studio.PgmetaImage) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world\n")) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - assert.NoError(t, Run(context.Background(), "", dbConfig, LangTypescript, []string{}, true, "", time.Second, fsys, conn.Intercept)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error when db is not started", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusServiceUnavailable) - // Run test - assert.Error(t, Run(context.Background(), "", dbConfig, LangTypescript, []string{}, true, "", time.Second, fsys)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on image fetch failure", func(t *testing.T) { - utils.Config.Api.Image = "v9" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images"). - Reply(http.StatusServiceUnavailable) - // Run test - assert.Error(t, Run(context.Background(), "", dbConfig, LangTypescript, []string{}, true, "", time.Second, fsys)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("generates swift types", func(t *testing.T) { - const containerId = "test-pgmeta" - imageUrl := utils.GetRegistryImageUrl(utils.Config.Studio.PgmetaImage) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world\n")) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - assert.NoError(t, Run(context.Background(), "", dbConfig, LangSwift, []string{}, true, SwiftInternalAccessControl, time.Second, fsys, conn.Intercept)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestGenLinkedCommand(t *testing.T) { - // Setup valid projectId id - projectId := apitest.RandomProjectRef() - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - t.Run("generates typescript types", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Flush pending mocks after test execution - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectId + "/types/typescript"). - Reply(200). - JSON(api.TypescriptResponseOutput{Types: ""}) - // Run test - assert.NoError(t, Run(context.Background(), projectId, pgconn.Config{}, LangTypescript, []string{}, true, "", time.Second, fsys)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on network failure", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Flush pending mocks after test execution - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectId + "/types/typescript"). - ReplyError(errNetwork) - // Run test - err := Run(context.Background(), projectId, pgconn.Config{}, LangTypescript, []string{}, true, "", time.Second, fsys) - // Validate api - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on service unavailable", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Flush pending mocks after test execution - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectId + "/types/typescript"). - Reply(http.StatusServiceUnavailable) - // Run test - assert.Error(t, Run(context.Background(), projectId, pgconn.Config{}, LangTypescript, []string{}, true, "", time.Second, fsys)) - }) -} - -func TestGenRemoteCommand(t *testing.T) { - dbConfig := pgconn.Config{ - Host: "db.supabase.co", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", - } - - t.Run("generates type from remote db", func(t *testing.T) { - const containerId = "test-pgmeta" - imageUrl := utils.GetRegistryImageUrl(utils.Config.Studio.PgmetaImage) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world\n")) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - assert.NoError(t, Run(context.Background(), "", dbConfig, LangTypescript, []string{"public"}, true, "", time.Second, afero.NewMemMapFs(), conn.Intercept)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/migration/apply/apply.go b/apps/cli-go/internal/migration/apply/apply.go deleted file mode 100644 index 18bd330ea6..0000000000 --- a/apps/cli-go/internal/migration/apply/apply.go +++ /dev/null @@ -1,68 +0,0 @@ -package apply - -import ( - "context" - "fmt" - "io/fs" - - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/migration/list" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -func MigrateAndSeed(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs) error { - // If pg-delta is enabled, the source of truth for migrations is always the migrations only or the declarative files. - // Declarative files must be used with `db schema declarative sync` commands always. - if viper.GetBool("EXPERIMENTAL") && len(version) == 0 && !utils.IsPgDeltaEnabled() { - if err := applySchemaFiles(ctx, conn, afero.NewIOFS(fsys)); err != nil { - return err - } - } else if err := applyMigrationFiles(ctx, version, conn, fsys); err != nil { - return err - } - return applySeedFiles(ctx, conn, fsys) -} - -func applyMigrationFiles(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs) error { - if !utils.Config.Db.Migrations.Enabled { - return nil - } - migrations, err := list.LoadPartialMigrations(version, fsys) - if err != nil { - return err - } - return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) -} - -func applySeedFiles(ctx context.Context, conn *pgx.Conn, fsys afero.Fs) error { - if !utils.Config.Db.Seed.Enabled { - return nil - } - seeds, err := migration.GetPendingSeeds(ctx, utils.Config.Db.Seed.SqlPaths, conn, afero.NewIOFS(fsys)) - if err != nil { - return err - } - return migration.SeedData(ctx, seeds, conn, afero.NewIOFS(fsys)) -} - -func applySchemaFiles(ctx context.Context, conn *pgx.Conn, fsys fs.FS) error { - declared, err := utils.Config.Db.Migrations.SchemaPaths.SQLFiles(fsys) - if len(declared) == 0 { - return err - } - for _, fp := range declared { - schema, err := migration.NewMigrationFromFile(fp, fsys) - if err != nil { - return err - } - schema.Version = "" - if err := schema.ExecBatch(ctx, conn); err != nil { - utils.CmdSuggestion = fmt.Sprintf("See schema file: %s", utils.Bold(fp)) - return err - } - } - return nil -} diff --git a/apps/cli-go/internal/migration/apply/apply_test.go b/apps/cli-go/internal/migration/apply/apply_test.go deleted file mode 100644 index f45891cfb4..0000000000 --- a/apps/cli-go/internal/migration/apply/apply_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package apply - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestMigrateDatabase(t *testing.T) { - t.Run("applies local migration", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - sql := "create schema public" - require.NoError(t, afero.WriteFile(fsys, path, []byte(sql), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(sql). - Reply("CREATE SCHEMA"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") - // Run test - err := MigrateAndSeed(context.Background(), "", conn.MockClient(t), fsys) - // Check error - assert.NoError(t, err) - }) - - t.Run("skip seeding when seed config is disabled", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - sql := "create schema public" - require.NoError(t, afero.WriteFile(fsys, path, []byte(sql), 0644)) - seedPath := filepath.Join(utils.SupabaseDirPath, "seed.sql") - // This will raise an error when seeding - require.NoError(t, afero.WriteFile(fsys, seedPath, []byte("INSERT INTO test_table;"), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(sql). - Reply("CREATE SCHEMA"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") - utils.Config.Db.Seed.Enabled = false - // Run test - err := MigrateAndSeed(context.Background(), "", conn.MockClient(t), fsys) - // No error should be returned since seeding is skipped - assert.NoError(t, err) - }) - - t.Run("ignores empty local directory", func(t *testing.T) { - assert.NoError(t, MigrateAndSeed(context.Background(), "", nil, afero.NewMemMapFs())) - }) - - t.Run("throws error on open failure", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Run test - err := MigrateAndSeed(context.Background(), "", nil, fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) -} diff --git a/apps/cli-go/internal/migration/down/down.go b/apps/cli-go/internal/migration/down/down.go deleted file mode 100644 index 35a9fa7e88..0000000000 --- a/apps/cli-go/internal/migration/down/down.go +++ /dev/null @@ -1,64 +0,0 @@ -package down - -import ( - "context" - "fmt" - "os" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/migration/apply" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/vault" -) - -func Run(ctx context.Context, last uint, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if last == 0 { - return errors.Errorf("--last must be greater than 0") - } - conn, err := utils.ConnectByConfig(ctx, config, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - remoteMigrations, err := migration.ListRemoteMigrations(ctx, conn) - if err != nil { - return err - } - total := uint(len(remoteMigrations)) - if total <= last { - utils.CmdSuggestion = fmt.Sprintf("Try %s if you want to revert all migrations.", utils.Aqua("supabase db reset")) - return errors.Errorf("--last must be smaller than total applied migrations: %d", total) - } - msg := confirmResetAll(remoteMigrations[total-last:]) - if shouldReset, err := utils.NewConsole().PromptYesNo(ctx, msg, false); err != nil { - return err - } else if !shouldReset { - return errors.New(context.Canceled) - } - version := remoteMigrations[total-last-1] - fmt.Fprintln(os.Stderr, "Resetting database to version:", version) - return ResetAll(ctx, version, conn, fsys) -} - -func ResetAll(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs) error { - if err := migration.DropUserSchemas(ctx, conn); err != nil { - return err - } - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { - return err - } - return apply.MigrateAndSeed(ctx, version, conn, fsys) -} - -func confirmResetAll(pending []string) string { - msg := fmt.Sprintln("Do you want to revert the following migrations?") - for _, v := range pending { - msg += fmt.Sprintf(" • %s\n", utils.Bold(v)) - } - msg += fmt.Sprintf("%s you will lose all data in this database.", utils.Yellow("WARNING:")) - return msg -} diff --git a/apps/cli-go/internal/migration/down/down_test.go b/apps/cli-go/internal/migration/down/down_test.go deleted file mode 100644 index 89d9ff66d4..0000000000 --- a/apps/cli-go/internal/migration/down/down_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package down - -import ( - "context" - "path/filepath" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: "127.0.0.1", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestMigrationsDown(t *testing.T) { - t.Run("resets last n migrations", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - files := []string{ - filepath.Join(utils.MigrationsDir, "20221201000000_test.sql"), - filepath.Join(utils.MigrationsDir, "20221201000001_test.sql"), - filepath.Join(utils.MigrationsDir, "20221201000002_test.sql"), - } - for _, path := range files { - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 2", []any{"20221201000000"}, []any{"20221201000001"}) - // Run test - err := Run(context.Background(), 1, dbConfig, fsys, conn.Intercept) - // Check error - assert.ErrorIs(t, err, context.Canceled) - }) - - t.Run("throws error on out of range", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 2", []any{"20221201000000"}, []any{"20221201000001"}) - // Run test - err := Run(context.Background(), 2, dbConfig, fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, "--last must be smaller than total applied migrations: 2") - }) - - t.Run("throws error on insufficient privilege", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") - // Run test - err := Run(context.Background(), 1, dbConfig, fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)") - }) -} - -func TestResetRemote(t *testing.T) { - t.Run("resets remote database", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_schema.sql") - require.NoError(t, afero.WriteFile(fsys, path, nil, 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.DropObjects). - Reply("INSERT 0") - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "schema", nil). - Reply("INSERT 0 1") - // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) - // Check error - assert.NoError(t, err) - }) - - t.Run("resets remote database with seed config disabled", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_schema.sql") - require.NoError(t, afero.WriteFile(fsys, path, nil, 0644)) - seedPath := filepath.Join(utils.SupabaseDirPath, "seed.sql") - // Will raise an error when seeding - require.NoError(t, afero.WriteFile(fsys, seedPath, []byte("INSERT INTO test_table;"), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.DropObjects). - Reply("INSERT 0") - helper.MockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(migration.INSERT_MIGRATION_VERSION, "0", "schema", nil). - Reply("INSERT 0 1") - utils.Config.Db.Seed.Enabled = false - // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) - // No error should be raised since we're skipping the seed - assert.NoError(t, err) - }) - - t.Run("throws error on drop schema failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.DropObjects). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") - // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) - // Check error - assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)") - }) -} diff --git a/apps/cli-go/internal/migration/list/list.go b/apps/cli-go/internal/migration/list/list.go deleted file mode 100644 index ad4a062c06..0000000000 --- a/apps/cli-go/internal/migration/list/list.go +++ /dev/null @@ -1,87 +0,0 @@ -package list - -import ( - "context" - "fmt" - "math" - "strconv" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -func Run(ctx context.Context, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - remoteVersions, err := loadRemoteVersions(ctx, config, options...) - if err != nil { - return err - } - localVersions, err := LoadLocalVersions(fsys) - if err != nil { - return err - } - table := makeTable(remoteVersions, localVersions) - return utils.RenderTable(table) -} - -func loadRemoteVersions(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) ([]string, error) { - conn, err := utils.ConnectByConfig(ctx, config, options...) - if err != nil { - return nil, err - } - defer conn.Close(context.Background()) - return migration.ListRemoteMigrations(ctx, conn) -} - -func makeTable(remoteMigrations, localMigrations []string) string { - var err error - table := "|Local|Remote|Time (UTC)|\n|-|-|-|\n" - for i, j := 0, 0; i < len(remoteMigrations) || j < len(localMigrations); { - remoteTimestamp := math.MaxInt - if i < len(remoteMigrations) { - if remoteTimestamp, err = strconv.Atoi(remoteMigrations[i]); err != nil { - i++ - continue - } - } - localTimestamp := math.MaxInt - if j < len(localMigrations) { - if localTimestamp, err = strconv.Atoi(localMigrations[j]); err != nil { - j++ - continue - } - } - // Top to bottom chronological order - if localTimestamp < remoteTimestamp { - table += fmt.Sprintf("|`%s`|` `|`%s`|\n", localMigrations[j], utils.FormatTimestampVersion(localMigrations[j])) - j++ - } else if remoteTimestamp < localTimestamp { - table += fmt.Sprintf("|` `|`%s`|`%s`|\n", remoteMigrations[i], utils.FormatTimestampVersion(remoteMigrations[i])) - i++ - } else { - table += fmt.Sprintf("|`%s`|`%s`|`%s`|\n", localMigrations[j], remoteMigrations[i], utils.FormatTimestampVersion(remoteMigrations[i])) - i++ - j++ - } - } - return table -} - -func LoadLocalVersions(fsys afero.Fs) ([]string, error) { - var versions []string - filter := func(v string) bool { - versions = append(versions, v) - return true - } - _, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys), filter) - return versions, err -} - -func LoadPartialMigrations(version string, fsys afero.Fs) ([]string, error) { - filter := func(v string) bool { - return version == "" || v <= version - } - return migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys), filter) -} diff --git a/apps/cli-go/internal/migration/list/list_test.go b/apps/cli-go/internal/migration/list/list_test.go deleted file mode 100644 index 9744174df9..0000000000 --- a/apps/cli-go/internal/migration/list/list_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package list - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: "127.0.0.1", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestMigrationList(t *testing.T) { - t.Run("lists remote migrations", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - err := Run(context.Background(), dbConfig, fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on remote failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), pgconn.Config{}, fsys) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("throws error on open failure", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - err := Run(context.Background(), dbConfig, fsys, conn.Intercept) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) -} - -func TestRemoteMigrations(t *testing.T) { - t.Run("loads migration versions", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{"20220727064247"}) - // Run test - versions, err := loadRemoteVersions(context.Background(), dbConfig, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"20220727064247"}, versions) - }) - - t.Run("throws error on connect failure", func(t *testing.T) { - // Run test - _, err := loadRemoteVersions(context.Background(), pgconn.Config{}) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("loads empty migrations on missing table", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - ReplyError(pgerrcode.UndefinedTable, "relation \"supabase_migrations.schema_migrations\" does not exist") - // Run test - versions, err := loadRemoteVersions(context.Background(), dbConfig, conn.Intercept) - // Check error - assert.NoError(t, err) - assert.Empty(t, versions) - }) - - t.Run("throws error on invalid row", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{}) - // Run test - _, err := loadRemoteVersions(context.Background(), dbConfig, conn.Intercept) - // Check error - assert.ErrorContains(t, err, "number of field descriptions must equal number of destinations, got 0 and 1") - }) -} - -func TestLocalMigrations(t *testing.T) { - t.Run("loads migration versions", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "20220727064246_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) - path = filepath.Join(utils.MigrationsDir, "20220727064248_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) - // Run test - versions, err := LoadLocalVersions(fsys) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"20220727064246", "20220727064248"}, versions) - }) - - t.Run("ignores outdated and invalid files", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "20211208000000_init.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) - path = filepath.Join(utils.MigrationsDir, "20211208000001_invalid.ts") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) - // Run test - versions, err := LoadLocalVersions(fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, versions) - }) - - t.Run("throws error on open failure", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Run test - _, err := LoadLocalVersions(fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) -} - -func TestMakeTable(t *testing.T) { - t.Run("tabulate version", func(t *testing.T) { - // Run test - table := makeTable([]string{"0", "2"}, []string{"0", "1"}) - // Check error - lines := strings.Split(strings.TrimSpace(table), "\n") - assert.ElementsMatch(t, []string{ - "|Local|Remote|Time (UTC)|", - "|-|-|-|", - "|`0`|`0`|`0`|", - "|`1`|` `|`1`|", - "|` `|`2`|`2`|", - }, lines) - }) - - t.Run("tabulate timestamp", func(t *testing.T) { - // Run test - table := makeTable([]string{"20220727064246", "20220727064248"}, []string{"20220727064246", "20220727064247"}) - // Check error - lines := strings.Split(strings.TrimSpace(table), "\n") - assert.ElementsMatch(t, []string{ - "|Local|Remote|Time (UTC)|", - "|-|-|-|", - "|`20220727064246`|`20220727064246`|`2022-07-27 06:42:46`|", - "|`20220727064247`|` `|`2022-07-27 06:42:47`|", - "|` `|`20220727064248`|`2022-07-27 06:42:48`|", - }, lines) - }) - - t.Run("ignores string values", func(t *testing.T) { - // Run test - table := makeTable([]string{"a", "c"}, []string{"a", "b"}) - // Check error - lines := strings.Split(strings.TrimSpace(table), "\n") - assert.ElementsMatch(t, []string{ - "|Local|Remote|Time (UTC)|", - "|-|-|-|", - }, lines) - }) -} diff --git a/apps/cli-go/internal/migration/new/new.go b/apps/cli-go/internal/migration/new/new.go deleted file mode 100644 index be232b5913..0000000000 --- a/apps/cli-go/internal/migration/new/new.go +++ /dev/null @@ -1,46 +0,0 @@ -package new - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" -) - -func Run(migrationName string, stdin afero.File, fsys afero.Fs) error { - path := GetMigrationPath(utils.GetCurrentTimestamp(), migrationName) - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return err - } - f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return errors.Errorf("failed to open migration file: %w", err) - } - defer func() { - fmt.Println("Created new migration at " + utils.Bold(path)) - // File descriptor will always be closed when process quits - _ = f.Close() - }() - return CopyStdinIfExists(stdin, f) -} - -func GetMigrationPath(timestamp, name string) string { - fullName := fmt.Sprintf("%s_%s.sql", timestamp, name) - return filepath.Join(utils.MigrationsDir, fullName) -} - -func CopyStdinIfExists(stdin afero.File, dst io.Writer) error { - if fi, err := stdin.Stat(); err != nil { - return errors.Errorf("failed to initialise stdin: %w", err) - } else if (fi.Mode() & os.ModeCharDevice) == 0 { - // Ref: https://stackoverflow.com/a/26567513 - if _, err := io.Copy(dst, stdin); err != nil { - return errors.Errorf("failed to copy from stdin: %w", err) - } - } - return nil -} diff --git a/apps/cli-go/internal/migration/new/new_test.go b/apps/cli-go/internal/migration/new/new_test.go deleted file mode 100644 index 39e2fe1156..0000000000 --- a/apps/cli-go/internal/migration/new/new_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package new - -import ( - "os" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestNewCommand(t *testing.T) { - t.Run("creates new migration file", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup empty stdin - stdin, err := fsys.Create("/dev/stdin") - require.NoError(t, err) - // Run test - assert.NoError(t, Run("test_migrate", stdin, fsys)) - // Validate output - files, err := afero.ReadDir(fsys, utils.MigrationsDir) - assert.NoError(t, err) - assert.Equal(t, 1, len(files)) - assert.Regexp(t, `([0-9]{14})_test_migrate\.sql`, files[0].Name()) - }) - - t.Run("streams content from pipe", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup stdin - r, w, err := os.Pipe() - require.NoError(t, err) - script := "create table pet;\ndrop table pet;\n" - _, err = w.WriteString(script) - require.NoError(t, err) - require.NoError(t, w.Close()) - // Run test - assert.NoError(t, Run("test_migrate", r, fsys)) - // Validate output - files, err := afero.ReadDir(fsys, utils.MigrationsDir) - assert.NoError(t, err) - assert.Equal(t, 1, len(files)) - path := filepath.Join(utils.MigrationsDir, files[0].Name()) - contents, err := afero.ReadFile(fsys, path) - assert.NoError(t, err) - assert.Equal(t, []byte(script), contents) - }) - - t.Run("throws error on failure to create directory", func(t *testing.T) { - // Setup read-only fs - fsys := afero.NewMemMapFs() - // Setup empty stdin - stdin, err := fsys.Create("/dev/stdin") - require.NoError(t, err) - // Run test - assert.Error(t, Run("test_migrate", stdin, afero.NewReadOnlyFs(fsys))) - }) - - t.Run("throws error on closed pipe", func(t *testing.T) { - // Setup read-only fs - fsys := afero.NewMemMapFs() - // Setup empty stdin - r, _, err := os.Pipe() - require.NoError(t, err) - require.NoError(t, r.Close()) - // Run test - assert.Error(t, Run("test_migrate", r, fsys)) - }) -} diff --git a/apps/cli-go/internal/migration/repair/repair.go b/apps/cli-go/internal/migration/repair/repair.go deleted file mode 100644 index a7dfee274e..0000000000 --- a/apps/cli-go/internal/migration/repair/repair.go +++ /dev/null @@ -1,108 +0,0 @@ -package repair - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strconv" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/migration/list" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -const ( - Applied = "applied" - Reverted = "reverted" -) - -var ErrInvalidVersion = errors.New("invalid version number") - -func Run(ctx context.Context, config pgconn.Config, version []string, status string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - for _, v := range version { - if _, err := strconv.Atoi(v); err != nil { - return errors.Errorf("failed to parse %s: %w", v, ErrInvalidVersion) - } - } - repairAll := len(version) == 0 - if repairAll { - msg := "Do you want to repair the entire migration history table to match local migration files?" - if shouldRepair, err := utils.NewConsole().PromptYesNo(ctx, msg, false); err != nil { - return err - } else if !shouldRepair { - return errors.New(context.Canceled) - } - local, err := list.LoadLocalVersions(fsys) - if err != nil { - return err - } - version = append(version, local...) - } - conn, err := utils.ConnectByConfig(ctx, config, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - // Update migration history - if err = UpdateMigrationTable(ctx, conn, version, status, repairAll, fsys); err == nil { - utils.CmdSuggestion = fmt.Sprintf("Run %s to show the updated migration history.", utils.Aqua("supabase migration list")) - } - return err -} - -func UpdateMigrationTable(ctx context.Context, conn *pgx.Conn, version []string, status string, repairAll bool, fsys afero.Fs) error { - if err := migration.CreateMigrationTable(ctx, conn); err != nil { - return err - } - // Data statements don't mutate schemas, safe to use statement cache - batch := &pgx.Batch{} - if repairAll { - batch.Queue(migration.TRUNCATE_VERSION_TABLE) - } - switch status { - case Applied: - for _, v := range version { - f, err := NewMigrationFromVersion(v, fsys) - if err != nil { - return err - } - batch.Queue(migration.UPSERT_MIGRATION_VERSION, f.Version, f.Name, f.Statements) - } - case Reverted: - if !repairAll { - batch.Queue(migration.DELETE_MIGRATION_VERSION, version) - } - } - if err := conn.SendBatch(ctx, batch).Close(); err != nil { - return errors.Errorf("failed to update migration table: %w", err) - } - if !repairAll { - fmt.Fprintf(os.Stderr, "Repaired migration history: %v => %s\n", version, status) - } - return nil -} - -func GetMigrationFile(version string, fsys afero.Fs) (string, error) { - path := filepath.Join(utils.MigrationsDir, version+"_*.sql") - matches, err := afero.Glob(fsys, path) - if err != nil { - return "", errors.Errorf("failed to glob migration files: %w", err) - } - if len(matches) == 0 { - return "", errors.Errorf("glob %s: %w", path, os.ErrNotExist) - } - return matches[0], nil -} - -func NewMigrationFromVersion(version string, fsys afero.Fs) (*migration.MigrationFile, error) { - name, err := GetMigrationFile(version, fsys) - if err != nil { - return nil, err - } - return migration.NewMigrationFromFile(name, afero.NewIOFS(fsys)) -} diff --git a/apps/cli-go/internal/migration/repair/repair_test.go b/apps/cli-go/internal/migration/repair/repair_test.go deleted file mode 100644 index 4c0bdb7cf3..0000000000 --- a/apps/cli-go/internal/migration/repair/repair_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package repair - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/testing/helper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: "db.supabase.com", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestRepairCommand(t *testing.T) { - t.Run("applies new version", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte("select 1"), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query(migration.UPSERT_MIGRATION_VERSION, "0", "test", []string{"select 1"}). - Reply("INSERT 0 1") - // Run test - err := Run(context.Background(), dbConfig, []string{"0"}, Applied, fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("reverts old version", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query(migration.DELETE_MIGRATION_VERSION, []string{"0"}). - Reply("DELETE 1") - // Run test - err := Run(context.Background(), dbConfig, []string{"0"}, Reverted, fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on invalid version", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), pgconn.Config{}, []string{"invalid"}, Applied, fsys) - // Check error - assert.ErrorIs(t, err, ErrInvalidVersion) - }) - - t.Run("throws error on connect failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), pgconn.Config{}, []string{"0"}, Applied, fsys) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - }) - - t.Run("throws error on insert failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query(migration.UPSERT_MIGRATION_VERSION, "0", "test", nil). - ReplyError(pgerrcode.DuplicateObject, `relation "supabase_migrations.schema_migrations" does not exist`) - // Run test - err := Run(context.Background(), dbConfig, []string{"0"}, Applied, fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: relation "supabase_migrations.schema_migrations" does not exist (SQLSTATE 42710)`) - }) -} - -func TestRepairAll(t *testing.T) { - t.Run("repairs whole history", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "y")) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte("select 1"), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query(strings.Join([]string{ - migration.TRUNCATE_VERSION_TABLE, - strings.ReplaceAll(migration.UPSERT_MIGRATION_VERSION, "$1, $2, $3", " '0' , 'test' , '{select 1}' "), - }, ";")). - Reply("TRUNCATE TABLE"). - Reply("INSERT 0 1") - // Run test - err := Run(context.Background(), dbConfig, nil, Applied, fsys, conn.Intercept, func(cc *pgx.ConnConfig) { - cc.PreferSimpleProtocol = true - }) - // Check error - assert.NoError(t, err) - }) - - t.Run("reverts whole history", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "y")) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - helper.MockMigrationHistory(conn). - Query(migration.TRUNCATE_VERSION_TABLE). - Reply("TRUNCATE TABLE") - // Run test - err := Run(context.Background(), dbConfig, nil, Reverted, fsys, conn.Intercept) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on cancel", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "n")) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), dbConfig, nil, Applied, fsys) - // Check error - assert.ErrorIs(t, err, context.Canceled) - }) - - t.Run("throws error on permission denied", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "y")) - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Run test - err := Run(context.Background(), dbConfig, nil, Applied, fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) -} diff --git a/apps/cli-go/internal/seed/buckets/buckets.go b/apps/cli-go/internal/seed/buckets/buckets.go deleted file mode 100644 index e077f5e716..0000000000 --- a/apps/cli-go/internal/seed/buckets/buckets.go +++ /dev/null @@ -1,84 +0,0 @@ -package buckets - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/spf13/afero" - "github.com/supabase/cli/internal/storage/client" - "github.com/supabase/cli/internal/utils" -) - -func Run(ctx context.Context, projectRef string, interactive bool, fsys afero.Fs) error { - hasVectorBuckets := len(utils.Config.Storage.VectorBuckets.Buckets) > 0 - if len(projectRef) == 0 && - len(utils.Config.Storage.Buckets) == 0 && - !hasVectorBuckets { - return nil - } - api, err := client.NewStorageAPI(ctx, projectRef) - if err != nil { - return err - } - console := utils.NewConsole() - if !interactive { - console.IsTTY = false - } - filter := func(bucketId string) bool { - label := fmt.Sprintf("Bucket %s already exists. Do you want to overwrite its properties?", utils.Bold(bucketId)) - shouldOverwrite, err := console.PromptYesNo(ctx, label, true) - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - return shouldOverwrite - } - if err := api.UpsertBuckets(ctx, utils.Config.Storage.Buckets, filter); err != nil { - return err - } - prune := func(name string) bool { - label := fmt.Sprintf("Bucket %s not found in %s. Do you want to prune it?", utils.Bold(name), utils.Bold(utils.ConfigPath)) - shouldPrune, err := console.PromptYesNo(ctx, label, false) - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - return shouldPrune - } - if utils.Config.Storage.AnalyticsBuckets.Enabled && len(projectRef) > 0 { - fmt.Fprintln(os.Stderr, "Updating analytics buckets...") - if err := api.UpsertAnalyticsBuckets(ctx, utils.Config.Storage.AnalyticsBuckets.Buckets, prune); err != nil { - return err - } - } - if utils.Config.Storage.VectorBuckets.Enabled && hasVectorBuckets { - fmt.Fprintln(os.Stderr, "Updating vector buckets...") - if err := api.UpsertVectorBuckets(ctx, utils.Config.Storage.VectorBuckets.Buckets, prune); err != nil { - if isVectorBucketsFeatureNotEnabled(err) { - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.") - return api.UpsertObjects(ctx, utils.Config.Storage.Buckets, utils.NewRootFS(fsys)) - } - if isLocalVectorBucketsUnavailable(err) { - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Vector buckets are not available in the local storage service. If this project is linked, run `supabase link` to update service versions, then restart the local stack. Skipping vector bucket seeding.") - return api.UpsertObjects(ctx, utils.Config.Storage.Buckets, utils.NewRootFS(fsys)) - } - return err - } - } - return api.UpsertObjects(ctx, utils.Config.Storage.Buckets, utils.NewRootFS(fsys)) -} - -func isVectorBucketsFeatureNotEnabled(err error) bool { - return err != nil && strings.Contains(err.Error(), "FeatureNotEnabled") -} - -func isLocalVectorBucketsUnavailable(err error) bool { - if err == nil { - return false - } - message := err.Error() - return strings.Contains(message, "Vector service not configured") || - (strings.Contains(message, "Error status 404:") && - strings.Contains(message, "Route POST:") && - strings.Contains(message, "ListVectorBuckets")) -} diff --git a/apps/cli-go/internal/seed/buckets/buckets_test.go b/apps/cli-go/internal/seed/buckets/buckets_test.go deleted file mode 100644 index 9527d3f554..0000000000 --- a/apps/cli-go/internal/seed/buckets/buckets_test.go +++ /dev/null @@ -1,297 +0,0 @@ -package buckets - -import ( - "context" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/BurntSushi/toml" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/storage" -) - -func TestSeedBuckets(t *testing.T) { - t.Run("seeds buckets", func(t *testing.T) { - t.Cleanup(func() { clear(utils.Config.Storage.Buckets) }) - config := ` -[test] -public = true -[private] -public = false` - require.NoError(t, toml.Unmarshal([]byte(config), &utils.Config.Storage.Buckets)) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - bucketPath := filepath.Join(utils.SupabaseDirPath, "images") - require.NoError(t, fsys.Mkdir(bucketPath, 0755)) - // Setup mock api - defer gock.OffAll() - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{{ - Name: "test", - Id: "test", - }}) - gock.New(utils.Config.Api.ExternalUrl). - Put("/storage/v1/bucket/test"). - Reply(http.StatusOK). - JSON(storage.UpdateBucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON(storage.CreateBucketResponse{Name: "private"}) - // Run test - err := Run(context.Background(), "", false, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("ignores unconfigured buckets", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.TargetMigration = "" - gock.OffAll() - }) - utils.Config.Storage.TargetMigration = "custom-metadata" - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusBadRequest). - JSON(map[string]string{ - "statusCode": "403", - "error": "Unauthorized", - "message": "new row violates row-level security policy", - }) - // Run test - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - // Check error - assert.NoError(t, err) - assert.Len(t, gock.Pending(), 1) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("does not call vector API when no vector buckets are configured", func(t *testing.T) { - t.Cleanup(func() { - clear(utils.Config.Storage.Buckets) - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - config := ` -[images] -public = true` - require.NoError(t, toml.Unmarshal([]byte(config), &utils.Config.Storage.Buckets)) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{} - - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON(storage.CreateBucketResponse{Name: "images"}) - - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("does not call storage API when no buckets are configured", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{} - - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("seeds vector buckets locally", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{ - "documents-openai": {}, - "existing-vec": {}, - } - // Setup mock api: regular buckets list is empty, vector list has one - // configured bucket plus one stale bucket that should be left alone - // because non-interactive prune defaults to false. - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/ListVectorBuckets"). - Reply(http.StatusOK). - JSON(storage.ListVectorBucketsResponse{ - VectorBuckets: []storage.VectorBucket{ - {VectorBucketName: "existing-vec"}, - {VectorBucketName: "stale-vec"}, - }, - }) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/CreateVectorBucket"). - Reply(http.StatusOK). - JSON(map[string]string{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/DeleteVectorBucket"). - Reply(http.StatusOK). - JSON(map[string]string{}) - // Run test - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // The DeleteVectorBucket mock should remain pending because non-interactive - // prune returns the default (false) and skips the delete. - pending := gock.Pending() - require.Len(t, pending, 1) - assert.Contains(t, pending[0].Request().URLStruct.Path, "DeleteVectorBucket") - }) - - t.Run("warns and continues when vector buckets are unavailable in the region", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{ - "documents-openai": {}, - } - - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/ListVectorBuckets"). - Reply(http.StatusBadRequest). - JSON(map[string]string{ - "code": "FeatureNotEnabled", - "message": "Feature is not enabled", - }) - - stderr := captureStderr(t, func() { - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - assert.NoError(t, err) - }) - - assert.Contains(t, stderr, "WARNING:") - assert.Contains(t, stderr, "Vector buckets are not available in this project's region yet") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("warns and continues when local vector storage is not configured", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{ - "documents-openai": {}, - } - - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/ListVectorBuckets"). - Reply(http.StatusConflict). - JSON(map[string]any{ - "statusCode": http.StatusConflict, - "code": "InvalidRequest", - "error": "InvalidRequest", - "message": "The feature Vector service not configured is not enabled for this resource", - }) - - stderr := captureStderr(t, func() { - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - assert.NoError(t, err) - }) - - assert.Contains(t, stderr, "WARNING:") - assert.Contains(t, stderr, "Vector buckets are not available in the local storage service") - assert.Contains(t, stderr, "supabase link") - assert.Contains(t, stderr, "restart the local stack") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("warns and continues when local vector routes are not registered", func(t *testing.T) { - t.Cleanup(func() { - utils.Config.Storage.VectorBuckets.Enabled = false - clear(utils.Config.Storage.VectorBuckets.Buckets) - gock.OffAll() - }) - utils.Config.Storage.VectorBuckets.Enabled = true - utils.Config.Storage.VectorBuckets.Buckets = map[string]struct{}{ - "documents-openai": {}, - } - - gock.New(utils.Config.Api.ExternalUrl). - Get("/storage/v1/bucket"). - Reply(http.StatusOK). - JSON([]storage.BucketResponse{}) - gock.New(utils.Config.Api.ExternalUrl). - Post("/storage/v1/vector/ListVectorBuckets"). - Reply(http.StatusNotFound). - JSON(map[string]any{ - "statusCode": http.StatusNotFound, - "error": "Not Found", - "message": "Route POST:/vector/ListVectorBuckets not found", - }) - - stderr := captureStderr(t, func() { - err := Run(context.Background(), "", false, afero.NewMemMapFs()) - assert.NoError(t, err) - }) - - assert.Contains(t, stderr, "WARNING:") - assert.Contains(t, stderr, "Vector buckets are not available in the local storage service") - assert.Contains(t, stderr, "supabase link") - assert.Contains(t, stderr, "restart the local stack") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func captureStderr(t *testing.T, run func()) string { - t.Helper() - reader, writer, err := os.Pipe() - require.NoError(t, err) - original := os.Stderr - os.Stderr = writer - t.Cleanup(func() { - os.Stderr = original - reader.Close() - }) - - run() - - require.NoError(t, writer.Close()) - os.Stderr = original - out, err := io.ReadAll(reader) - require.NoError(t, err) - return strings.TrimSpace(string(out)) -} diff --git a/apps/cli-go/internal/status/status.go b/apps/cli-go/internal/status/status.go deleted file mode 100644 index 6596319813..0000000000 --- a/apps/cli-go/internal/status/status.go +++ /dev/null @@ -1,392 +0,0 @@ -package status - -import ( - "context" - "crypto/tls" - "crypto/x509" - _ "embed" - "fmt" - "io" - "net/http" - "net/url" - "os" - "slices" - "sync" - "time" - - "github.com/Netflix/go-env" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/go-errors/errors" - "github.com/olekukonko/tablewriter" - "github.com/olekukonko/tablewriter/tw" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/fetcher" -) - -type CustomName struct { - ApiURL string `env:"api.url,default=API_URL"` - RestURL string `env:"api.rest_url,default=REST_URL"` - GraphqlURL string `env:"api.graphql_url,default=GRAPHQL_URL"` - StorageS3URL string `env:"api.storage_s3_url,default=STORAGE_S3_URL"` - McpURL string `env:"api.mcp_url,default=MCP_URL"` - FunctionsURL string `env:"api.functions_url,default=FUNCTIONS_URL"` - DbURL string `env:"db.url,default=DB_URL"` - StudioURL string `env:"studio.url,default=STUDIO_URL"` - InbucketURL string `env:"inbucket.url,default=INBUCKET_URL,deprecated"` - MailpitURL string `env:"mailpit.url,default=MAILPIT_URL"` - PublishableKey string `env:"auth.publishable_key,default=PUBLISHABLE_KEY"` - SecretKey string `env:"auth.secret_key,default=SECRET_KEY"` - JWTSecret string `env:"auth.jwt_secret,default=JWT_SECRET,deprecated"` - AnonKey string `env:"auth.anon_key,default=ANON_KEY,deprecated"` - ServiceRoleKey string `env:"auth.service_role_key,default=SERVICE_ROLE_KEY,deprecated"` - StorageS3AccessKeyId string `env:"storage.s3_access_key_id,default=S3_PROTOCOL_ACCESS_KEY_ID"` - StorageS3SecretAccessKey string `env:"storage.s3_secret_access_key,default=S3_PROTOCOL_ACCESS_KEY_SECRET"` - StorageS3Region string `env:"storage.s3_region,default=S3_PROTOCOL_REGION"` -} - -func (c *CustomName) toValues(exclude ...string) map[string]string { - values := map[string]string{ - c.DbURL: fmt.Sprintf("postgresql://%s@%s:%d/postgres", url.UserPassword("postgres", utils.Config.Db.Password), utils.Config.Hostname, utils.Config.Db.Port), - } - - kongEnabled := utils.Config.Api.Enabled && !slices.Contains(exclude, utils.KongId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.KongImage)) - postgrestEnabled := kongEnabled && !slices.Contains(exclude, utils.RestId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image)) - studioEnabled := utils.Config.Studio.Enabled && !slices.Contains(exclude, utils.StudioId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Studio.Image)) - authEnabled := utils.Config.Auth.Enabled && !slices.Contains(exclude, utils.GotrueId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Auth.Image)) - inbucketEnabled := utils.Config.Inbucket.Enabled && !slices.Contains(exclude, utils.InbucketId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image)) - storageEnabled := utils.Config.Storage.Enabled && !slices.Contains(exclude, utils.StorageId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Storage.Image)) - functionsEnabled := utils.Config.EdgeRuntime.Enabled && !slices.Contains(exclude, utils.EdgeRuntimeId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.EdgeRuntime.Image)) - - if kongEnabled { - values[c.ApiURL] = utils.Config.Api.ExternalUrl - if postgrestEnabled { - values[c.RestURL] = utils.GetApiUrl("/rest/v1") - values[c.GraphqlURL] = utils.GetApiUrl("/graphql/v1") - } - if functionsEnabled { - values[c.FunctionsURL] = utils.GetApiUrl("/functions/v1") - } - if studioEnabled { - values[c.McpURL] = utils.GetApiUrl("/mcp") - } - } - if studioEnabled { - values[c.StudioURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Studio.Port) - } - if authEnabled { - values[c.PublishableKey] = utils.Config.Auth.PublishableKey.Value - values[c.SecretKey] = utils.Config.Auth.SecretKey.Value - values[c.JWTSecret] = utils.Config.Auth.JwtSecret.Value - values[c.AnonKey] = utils.Config.Auth.AnonKey.Value - values[c.ServiceRoleKey] = utils.Config.Auth.ServiceRoleKey.Value - } - if inbucketEnabled { - values[c.MailpitURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port) - values[c.InbucketURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port) - } - if storageEnabled && utils.Config.Storage.S3Protocol != nil && utils.Config.Storage.S3Protocol.Enabled { - values[c.StorageS3URL] = utils.GetApiUrl("/storage/v1/s3") - values[c.StorageS3AccessKeyId] = utils.Config.Storage.S3Credentials.AccessKeyId - values[c.StorageS3SecretAccessKey] = utils.Config.Storage.S3Credentials.SecretAccessKey - values[c.StorageS3Region] = utils.Config.Storage.S3Credentials.Region - } - return values -} - -func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs, ignoreHealthCheck bool, exclude ...string) error { - // Sanity checks. - if err := flags.LoadConfig(fsys); err != nil { - return err - } - if !ignoreHealthCheck { - if err := assertContainerHealthy(ctx, utils.DbId); err != nil { - return err - } - } - stopped, err := checkServiceHealth(ctx) - if err != nil { - return err - } - if len(stopped) > 0 { - fmt.Fprintln(os.Stderr, "Stopped services:", stopped) - } - excluded := append(stopped, exclude...) - if format == utils.OutputPretty { - fmt.Fprintf(os.Stderr, "%s local development setup is running.\n\n", utils.Aqua("supabase")) - PrettyPrint(os.Stdout, excluded...) - return nil - } - return printStatus(names, format, os.Stdout, excluded...) -} - -func checkServiceHealth(ctx context.Context) ([]string, error) { - resp, err := utils.Docker.ContainerList(ctx, container.ListOptions{ - Filters: utils.CliProjectFilter(utils.Config.ProjectId), - }) - if err != nil { - return nil, errors.Errorf("failed to list running containers: %w", err) - } - running := make(map[string]struct{}, len(resp)) - for _, c := range resp { - for _, n := range c.Names { - running[n] = struct{}{} - } - } - var stopped []string - for _, containerId := range utils.GetDockerIds() { - if _, ok := running["/"+containerId]; !ok { - stopped = append(stopped, containerId) - } - } - return stopped, nil -} - -func assertContainerHealthy(ctx context.Context, container string) error { - if resp, err := utils.Docker.ContainerInspect(ctx, container); err != nil { - return errors.Errorf("failed to inspect container health: %w", err) - } else if !resp.State.Running { - return errors.Errorf("%s container is not running: %s", container, resp.State.Status) - } else if resp.State.Health != nil && resp.State.Health.Status != types.Healthy { - return errors.Errorf("%s container is not ready: %s", container, resp.State.Health.Status) - } - return nil -} - -func IsServiceReady(ctx context.Context, container string) error { - if container == utils.RestId { - // PostgREST does not support native health checks - return checkHTTPHead(ctx, "/rest-admin/v1/ready") - } - if container == utils.EdgeRuntimeId { - // Native health check logs too much hyper::Error(IncompleteMessage) - return checkHTTPHead(ctx, "/functions/v1/_internal/health") - } - return assertContainerHealthy(ctx, container) -} - -// NewKongClient returns an HTTP client configured for the local Kong gateway. -// It deliberately omits http.Client.Timeout: this client is shared with -// streaming storage uploads, where a full-request deadline would truncate -// large transfers under load. Short-lived callers (e.g. health probes) should -// bound themselves with a per-call context deadline instead. -// -// To regenerate local certificate pair: -// -// openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \ -// -nodes -keyout kong.local.key -out kong.local.crt -subj "/CN=localhost" \ -// -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" -func NewKongClient() *http.Client { - client := &http.Client{} - if t, ok := http.DefaultTransport.(*http.Transport); ok { - pool, err := x509.SystemCertPool() - if err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - pool = x509.NewCertPool() - } - // No need to replace TLS config if we fail to append cert - if pool.AppendCertsFromPEM(utils.Config.Api.Tls.CertContent) { - rt := t.Clone() - rt.TLSClientConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: pool, - } - client.Transport = rt - } - } - return client -} - -var ( - healthClient *fetcher.Fetcher - healthOnce sync.Once -) - -// healthProbeTimeout caps a single readiness probe so a hung response cannot -// stall the surrounding retry loop in WaitForHealthyService. -const healthProbeTimeout = 10 * time.Second - -func checkHTTPHead(ctx context.Context, path string) error { - healthOnce.Do(func() { - healthClient = fetcher.NewServiceGateway( - utils.Config.Api.ExternalUrl, - utils.Config.Auth.SecretKey.Value, - fetcher.WithHTTPClient(NewKongClient()), - fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - ) - }) - ctx, cancel := context.WithTimeout(ctx, healthProbeTimeout) - defer cancel() - // HEAD method does not return response body - resp, err := healthClient.Send(ctx, http.MethodHead, path, nil) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -func printStatus(names CustomName, format string, w io.Writer, exclude ...string) (err error) { - values := names.toValues(exclude...) - return utils.EncodeOutput(format, w, values) -} - -func PrettyPrint(w io.Writer, exclude ...string) { - logger := utils.GetDebugLogger() - - names := CustomName{} - if err := env.Unmarshal(env.EnvSet{}, &names); err != nil { - fmt.Fprintln(logger, err) - } - values := names.toValues(exclude...) - - groups := []OutputGroup{ - { - Name: "🔧 Development Tools", - Items: []OutputItem{ - {Label: "Studio", Value: values[names.StudioURL], Type: Link}, - {Label: "Mailpit", Value: values[names.MailpitURL], Type: Link}, - {Label: "MCP", Value: values[names.McpURL], Type: Link}, - }, - }, - { - Name: "🌐 APIs", - Items: []OutputItem{ - {Label: "Project URL", Value: values[names.ApiURL], Type: Link}, - {Label: "REST", Value: values[names.RestURL], Type: Link}, - {Label: "GraphQL", Value: values[names.GraphqlURL], Type: Link}, - {Label: "Edge Functions", Value: values[names.FunctionsURL], Type: Link}, - }, - }, - { - Name: "⛁ Database", - Items: []OutputItem{ - {Label: "URL", Value: values[names.DbURL], Type: Link}, - }, - }, - { - Name: "🔑 Authentication Keys", - Items: []OutputItem{ - {Label: "Publishable", Value: values[names.PublishableKey], Type: Key}, - {Label: "Secret", Value: values[names.SecretKey], Type: Key}, - }, - }, - { - Name: "📦 Storage (S3)", - Items: []OutputItem{ - {Label: "URL", Value: values[names.StorageS3URL], Type: Link}, - {Label: "Access Key", Value: values[names.StorageS3AccessKeyId], Type: Key}, - {Label: "Secret Key", Value: values[names.StorageS3SecretAccessKey], Type: Key}, - {Label: "Region", Value: values[names.StorageS3Region], Type: Text}, - }, - }, - } - - for _, group := range groups { - if err := group.printTable(w); err != nil { - fmt.Fprintln(logger, err) - } else { - fmt.Fprintln(w) - } - } -} - -type OutputType string - -const ( - Text OutputType = "text" - Link OutputType = "link" - Key OutputType = "key" -) - -type OutputItem struct { - Label string - Value string - Type OutputType -} - -type OutputGroup struct { - Name string - Items []OutputItem -} - -func (g *OutputGroup) printTable(w io.Writer) error { - table := tablewriter.NewTable(w, - // Rounded corners - tablewriter.WithSymbols(tw.NewSymbols(tw.StyleRounded)), - - // Table content formatting - tablewriter.WithConfig(tablewriter.Config{ - Header: tw.CellConfig{ - Formatting: tw.CellFormatting{ - AutoFormat: tw.Off, - MergeMode: tw.MergeHorizontal, - }, - Alignment: tw.CellAlignment{ - Global: tw.AlignLeft, - }, - Filter: tw.CellFilter{ - Global: func(s []string) []string { - for i := range s { - s[i] = utils.Bold(s[i]) - } - return s - }, - }, - }, - Row: tw.CellConfig{ - Alignment: tw.CellAlignment{ - Global: tw.AlignLeft, - }, - ColMaxWidths: tw.CellWidth{ - PerColumn: map[int]int{0: 16}, - }, - Filter: tw.CellFilter{ - PerColumn: []func(string) string{ - func(s string) string { - return utils.Green(s) - }, - }, - }, - }, - Behavior: tw.Behavior{ - Compact: tw.Compact{ - Merge: tw.On, - }, - }, - }), - - // Set title as header (merged across all columns) - tablewriter.WithHeader([]string{g.Name, g.Name}), - ) - - // Add data rows with values colored based on type - shouldRender := false - for _, row := range g.Items { - if row.Value == "" { - continue - } - value := row.Value - switch row.Type { - case Link: - value = utils.Aqua(row.Value) - case Key: - value = utils.Yellow(row.Value) - } - if err := table.Append(row.Label, value); err != nil { - return errors.Errorf("failed to append row: %w", err) - } - shouldRender = true - } - - // Ensure at least one item in the group is non-empty - if shouldRender { - if err := table.Render(); err != nil { - return errors.Errorf("failed to render table: %w", err) - } - } - - return nil -} diff --git a/apps/cli-go/internal/status/status_test.go b/apps/cli-go/internal/status/status_test.go deleted file mode 100644 index 37b309b7b1..0000000000 --- a/apps/cli-go/internal/status/status_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package status - -import ( - "bytes" - "context" - "errors" - "net/http" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" -) - -func TestStatusCommand(t *testing.T) { - t.Run("shows service status", func(t *testing.T) { - var running []container.Summary - for _, name := range utils.GetDockerIds() { - running = append(running, container.Summary{ - Names: []string{name + "_test"}, - }) - } - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.InitConfig(utils.InitParams{ProjectId: "test"}, fsys)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). - Reply(http.StatusOK). - JSON(running) - // Run test - assert.NoError(t, Run(context.Background(), CustomName{}, utils.OutputPretty, fsys, false)) - // Check error - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("skips database health check when ignored", func(t *testing.T) { - var running []container.Summary - for _, name := range utils.GetDockerIds() { - running = append(running, container.Summary{ - Names: []string{name + "_test"}, - }) - } - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.InitConfig(utils.InitParams{ProjectId: "test"}, fsys)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). - Reply(http.StatusOK). - JSON(running) - // Run test - assert.NoError(t, Run(context.Background(), CustomName{}, utils.OutputPretty, fsys, true)) - // Check error - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on malformed config", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) - // Run test - err := Run(context.Background(), CustomName{}, utils.OutputPretty, fsys, false) - // Check error - assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") - }) - - t.Run("throws error on missing docker", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_"). - ReplyError(errors.New("network error")) - // Run test - err := Run(context.Background(), CustomName{}, utils.OutputPretty, fsys, false) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestServiceHealth(t *testing.T) { - t.Run("checks all services", func(t *testing.T) { - var running []container.Summary - for _, name := range utils.GetDockerIds() { - running = append(running, container.Summary{ - Names: []string{"/" + name}, - }) - } - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). - Reply(http.StatusOK). - JSON(running) - // Run test - stopped, err := checkServiceHealth(context.Background()) - // Check error - assert.NoError(t, err) - assert.Empty(t, stopped) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("shows stopped container", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). - Reply(http.StatusOK). - JSON([]container.Summary{}) - // Run test - stopped, err := checkServiceHealth(context.Background()) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, utils.GetDockerIds(), stopped) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on network error", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). - ReplyError(errNetwork) - // Run test - stopped, err := checkServiceHealth(context.Background()) - // Check error - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, stopped) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestPrintStatus(t *testing.T) { - utils.Config.Db.Port = 0 - utils.Config.Hostname = "127.0.0.1" - utils.Config.Api.Enabled = false - utils.Config.Auth.Enabled = false - utils.Config.Storage.Enabled = false - utils.Config.Realtime.Enabled = false - utils.Config.Studio.Enabled = false - utils.Config.Analytics.Enabled = false - utils.Config.Inbucket.Enabled = false - - t.Run("outputs env var", func(t *testing.T) { - utils.Config.Hostname = "127.0.0.1" - // Run test - var stdout bytes.Buffer - assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputEnv, &stdout)) - // Check error - assert.Equal(t, "DB_URL=\"postgresql://postgres:postgres@127.0.0.1:0/postgres\"\n", stdout.String()) - }) - - t.Run("outputs json object", func(t *testing.T) { - // Run test - var stdout bytes.Buffer - assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputJson, &stdout)) - // Check error - assert.Equal(t, "{\n \"DB_URL\": \"postgresql://postgres:postgres@127.0.0.1:0/postgres\"\n}\n", stdout.String()) - }) - - t.Run("omits excluded services from json object", func(t *testing.T) { - utils.Config.Studio.Enabled = true - utils.Config.Studio.Port = 54323 - t.Cleanup(func() { - utils.Config.Studio.Enabled = false - }) - // Run test - var stdout bytes.Buffer - assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL", StudioURL: "STUDIO_URL"}, utils.OutputJson, &stdout, utils.StudioId)) - // Check error - assert.Equal(t, "{\n \"DB_URL\": \"postgresql://postgres:postgres@127.0.0.1:0/postgres\"\n}\n", stdout.String()) - }) - - t.Run("outputs yaml properties", func(t *testing.T) { - // Run test - var stdout bytes.Buffer - assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputYaml, &stdout)) - // Check error - assert.Equal(t, "DB_URL: postgresql://postgres:postgres@127.0.0.1:0/postgres\n", stdout.String()) - }) - - t.Run("outputs toml fields", func(t *testing.T) { - // Run test - var stdout bytes.Buffer - assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputToml, &stdout)) - // Check error - assert.Equal(t, "DB_URL = \"postgresql://postgres:postgres@127.0.0.1:0/postgres\"\n", stdout.String()) - }) -} diff --git a/apps/cli-go/internal/storage/client/api.go b/apps/cli-go/internal/storage/client/api.go deleted file mode 100644 index 33b5d6711f..0000000000 --- a/apps/cli-go/internal/storage/client/api.go +++ /dev/null @@ -1,46 +0,0 @@ -package client - -import ( - "context" - "net/http" - - "github.com/spf13/viper" - "github.com/supabase/cli/internal/status" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/tenant" - "github.com/supabase/cli/pkg/fetcher" - "github.com/supabase/cli/pkg/storage" -) - -func NewStorageAPI(ctx context.Context, projectRef string) (storage.StorageAPI, error) { - client := storage.StorageAPI{} - if len(projectRef) == 0 { - client.Fetcher = newLocalClient() - } else if viper.IsSet("AUTH_SERVICE_ROLE_KEY") { - // Special case for calling storage API without personal access token - client.Fetcher = newRemoteClient(projectRef, utils.Config.Auth.ServiceRoleKey.Value) - } else if apiKey, err := tenant.GetApiKeys(ctx, projectRef); err == nil { - client.Fetcher = newRemoteClient(projectRef, apiKey.ServiceRole) - } else { - return client, err - } - return client, nil -} - -func newLocalClient() *fetcher.Fetcher { - return fetcher.NewServiceGateway( - utils.Config.Api.ExternalUrl, - utils.Config.Auth.ServiceRoleKey.Value, - fetcher.WithHTTPClient(status.NewKongClient()), - fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - ) -} - -func newRemoteClient(projectRef, token string) *fetcher.Fetcher { - return fetcher.NewServiceGateway( - "https://"+utils.GetSupabaseHost(projectRef), - token, - fetcher.WithHTTPClient(http.DefaultClient), - fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - ) -} diff --git a/apps/cli-go/internal/storage/client/scheme.go b/apps/cli-go/internal/storage/client/scheme.go deleted file mode 100644 index bb3254f5ba..0000000000 --- a/apps/cli-go/internal/storage/client/scheme.go +++ /dev/null @@ -1,38 +0,0 @@ -package client - -import ( - "net/url" - "strings" - - "github.com/go-errors/errors" -) - -const STORAGE_SCHEME = "ss" - -var ErrInvalidURL = errors.New("URL must match pattern ss:///bucket/[prefix]") - -func ParseStorageURL(objectURL string) (string, error) { - parsed, err := url.Parse(objectURL) - if err != nil { - return "", errors.Errorf("failed to parse storage url: %w", err) - } - if !strings.EqualFold(parsed.Scheme, STORAGE_SCHEME) || len(parsed.Path) == 0 || len(parsed.Host) > 0 { - return "", errors.New(ErrInvalidURL) - } - return parsed.Path, nil -} - -func SplitBucketPrefix(objectPath string) (string, string) { - if objectPath == "" || objectPath == "/" { - return "", "" - } - start := 0 - if objectPath[0] == '/' { - start = 1 - } - sep := strings.IndexByte(objectPath[start:], '/') - if sep < 0 { - return objectPath[start:], "" - } - return objectPath[start : sep+start], objectPath[sep+start+1:] -} diff --git a/apps/cli-go/internal/storage/client/scheme_test.go b/apps/cli-go/internal/storage/client/scheme_test.go deleted file mode 100644 index 5da6191a02..0000000000 --- a/apps/cli-go/internal/storage/client/scheme_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package client - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestParseStorageURL(t *testing.T) { - t.Run("parses valid url", func(t *testing.T) { - path, err := ParseStorageURL("ss:///bucket/folder/name.png") - assert.NoError(t, err) - assert.Equal(t, path, "/bucket/folder/name.png") - }) - - t.Run("throws error on invalid host", func(t *testing.T) { - path, err := ParseStorageURL("ss://bucket") - assert.ErrorIs(t, err, ErrInvalidURL) - assert.Empty(t, path) - }) - - t.Run("throws error on missing path", func(t *testing.T) { - path, err := ParseStorageURL("ss:") - assert.ErrorIs(t, err, ErrInvalidURL) - assert.Empty(t, path) - }) - - t.Run("throws error on invalid scheme", func(t *testing.T) { - path, err := ParseStorageURL(".") - assert.ErrorIs(t, err, ErrInvalidURL) - assert.Empty(t, path) - }) - - t.Run("throws error on invalid url", func(t *testing.T) { - path, err := ParseStorageURL(":") - assert.ErrorContains(t, err, "missing protocol scheme") - assert.Empty(t, path) - }) -} - -func TestSplitBucketPrefix(t *testing.T) { - t.Run("splits empty path", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("") - assert.Equal(t, bucket, "") - assert.Equal(t, prefix, "") - }) - - t.Run("splits root path", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("/") - assert.Equal(t, bucket, "") - assert.Equal(t, prefix, "") - }) - - t.Run("splits no slash", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("bucket") - assert.Equal(t, bucket, "bucket") - assert.Equal(t, prefix, "") - }) - - t.Run("splits prefix slash", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("/bucket") - assert.Equal(t, bucket, "bucket") - assert.Equal(t, prefix, "") - }) - - t.Run("splits suffix slash", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("bucket/") - assert.Equal(t, bucket, "bucket") - assert.Equal(t, prefix, "") - }) - - t.Run("splits file path", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("/bucket/folder/name.png") - assert.Equal(t, bucket, "bucket") - assert.Equal(t, prefix, "folder/name.png") - }) - - t.Run("splits dir path", func(t *testing.T) { - bucket, prefix := SplitBucketPrefix("/bucket/folder/") - assert.Equal(t, bucket, "bucket") - assert.Equal(t, prefix, "folder/") - }) -} diff --git a/apps/cli-go/internal/telemetry/client.go b/apps/cli-go/internal/telemetry/client.go deleted file mode 100644 index 717e4af181..0000000000 --- a/apps/cli-go/internal/telemetry/client.go +++ /dev/null @@ -1,149 +0,0 @@ -package telemetry - -import ( - "log" - "net/http" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/posthog/posthog-go" - "github.com/supabase/cli/internal/utils" -) - -type Analytics interface { - Enabled() bool - Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error - Identify(distinctID string, properties map[string]any) error - Alias(distinctID string, alias string) error - GroupIdentify(groupType string, groupKey string, properties map[string]any) error - Close() error -} - -type queueClient interface { - Enqueue(posthog.Message) error - Close() error -} - -type constructor func(apiKey string, config posthog.Config) (queueClient, error) - -const shutdownTimeout = 2 * time.Second - -type Client struct { - client queueClient - baseProperties posthog.Properties -} - -func NewClient(apiKey string, endpoint string, baseProperties map[string]any, factory constructor) (*Client, error) { - if strings.TrimSpace(apiKey) == "" { - return &Client{baseProperties: makeProperties(baseProperties)}, nil - } - if factory == nil { - factory = func(apiKey string, config posthog.Config) (queueClient, error) { - return posthog.NewWithConfig(apiKey, config) - } - } - // Without a positive ShutdownTimeout, Close blocks indefinitely when the - // endpoint is unreachable, hanging every command on networks that block - // PostHog; keep it tight because blackholed networks pay the whole timeout - // at exit. The default logger prints delivery failures to stderr even for - // commands that succeeded, so route them to the --debug logger instead. - config := posthog.Config{ - ShutdownTimeout: shutdownTimeout, - Logger: posthog.StdLogger(log.New(utils.GetDebugLogger(), "posthog ", log.LstdFlags), true), - } - if endpoint != "" { - config.Endpoint = endpoint - } - // Preserve the active process-wide transport, which may be wrapped by debug.NewTransport() - // instead of assuming http.DefaultTransport is always a *http.Transport. - config.Transport = http.DefaultTransport - client, err := factory(apiKey, config) - if err != nil { - return nil, errors.Errorf("failed to initialize posthog client: %w", err) - } - return &Client{ - client: client, - baseProperties: makeProperties(baseProperties), - }, nil -} - -func (c *Client) Enabled() bool { - return c != nil && c.client != nil -} - -func (c *Client) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { - if !c.Enabled() { - return nil - } - msg := posthog.Capture{ - DistinctId: distinctID, - Event: event, - Properties: c.properties(properties), - } - if len(groups) > 0 { - msg.Groups = makeGroups(groups) - } - return c.client.Enqueue(msg) -} - -func (c *Client) Identify(distinctID string, properties map[string]any) error { - if !c.Enabled() { - return nil - } - return c.client.Enqueue(posthog.Identify{ - DistinctId: distinctID, - Properties: c.properties(properties), - }) -} - -func (c *Client) Alias(distinctID string, alias string) error { - if !c.Enabled() { - return nil - } - return c.client.Enqueue(posthog.Alias{ - DistinctId: distinctID, - Alias: alias, - }) -} - -func (c *Client) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { - if !c.Enabled() { - return nil - } - return c.client.Enqueue(posthog.GroupIdentify{ - Type: groupType, - Key: groupKey, - Properties: c.properties(properties), - }) -} - -func (c *Client) Close() error { - if !c.Enabled() { - return nil - } - return c.client.Close() -} - -func (c *Client) properties(properties map[string]any) posthog.Properties { - merged := posthog.NewProperties() - merged.Merge(c.baseProperties) - merged.Merge(makeProperties(properties)) - return merged -} - -func makeProperties(values map[string]any) posthog.Properties { - props := posthog.NewProperties() - for key, value := range values { - props.Set(key, value) - } - return props -} - -func makeGroups(values map[string]string) posthog.Groups { - groups := posthog.NewGroups() - for key, value := range values { - groups.Set(key, value) - } - return groups -} diff --git a/apps/cli-go/internal/telemetry/client_test.go b/apps/cli-go/internal/telemetry/client_test.go deleted file mode 100644 index bcc8db7d24..0000000000 --- a/apps/cli-go/internal/telemetry/client_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package telemetry - -import ( - "io" - "net/http" - "os" - "testing" - "time" - - "github.com/posthog/posthog-go" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/debug" -) - -type fakeQueue struct { - messages []posthog.Message - closed bool -} - -func (f *fakeQueue) Enqueue(msg posthog.Message) error { - f.messages = append(f.messages, msg) - return nil -} - -func (f *fakeQueue) Close() error { - f.closed = true - return nil -} - -func TestNewClient(t *testing.T) { - t.Run("uses endpoint and enables analytics when key is set", func(t *testing.T) { - var gotKey string - var gotConfig posthog.Config - - client, err := NewClient("phc_test", "https://eu.i.posthog.com", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { - gotKey = apiKey - gotConfig = config - return &fakeQueue{}, nil - }) - - require.NoError(t, err) - assert.True(t, client.Enabled()) - assert.Equal(t, "phc_test", gotKey) - assert.Equal(t, "https://eu.i.posthog.com", gotConfig.Endpoint) - assert.Equal(t, 2*time.Second, gotConfig.ShutdownTimeout) - assert.NotNil(t, gotConfig.Logger) - }) - - t.Run("becomes a no-op when key is empty", func(t *testing.T) { - client, err := NewClient("", "https://eu.i.posthog.com", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { - t.Fatalf("constructor should not be called without an api key") - return nil, nil - }) - - require.NoError(t, err) - assert.False(t, client.Enabled()) - assert.NoError(t, client.Capture("device-1", EventCommandExecuted, map[string]any{"command": "login"}, nil)) - assert.NoError(t, client.Close()) - }) - t.Run("routes posthog logs to stderr only in debug mode", func(t *testing.T) { - originalStderr := os.Stderr - originalDebug := viper.GetBool("DEBUG") - reader, writer, err := os.Pipe() - require.NoError(t, err) - os.Stderr = writer - t.Cleanup(func() { - os.Stderr = originalStderr - viper.Set("DEBUG", originalDebug) - }) - - configFor := func(debugEnabled bool) posthog.Config { - viper.Set("DEBUG", debugEnabled) - var gotConfig posthog.Config - _, err := NewClient("phc_test", "", nil, func(apiKey string, config posthog.Config) (queueClient, error) { - gotConfig = config - return &fakeQueue{}, nil - }) - require.NoError(t, err) - return gotConfig - } - - configFor(false).Logger.Errorf("dropped %d messages", 1) - configFor(true).Logger.Errorf("dropped %d messages", 2) - - require.NoError(t, writer.Close()) - output, err := io.ReadAll(reader) - require.NoError(t, err) - assert.NotContains(t, string(output), "dropped 1 messages") - assert.Contains(t, string(output), "dropped 2 messages") - }) - t.Run("works when debug wraps the default transport", func(t *testing.T) { - original := http.DefaultTransport - http.DefaultTransport = debug.NewTransport() - t.Cleanup(func() { - http.DefaultTransport = original - }) - - client, err := NewClient("phc_test", "https://eu.i.posthog.com", map[string]any{"platform": "cli"}, nil) - - require.NoError(t, err) - require.NotNil(t, client) - assert.True(t, client.Enabled()) - assert.NoError(t, client.Close()) - }) -} - -func TestCaptureMergesBasePropertiesAndGroups(t *testing.T) { - queue := &fakeQueue{} - client, err := NewClient("phc_test", "https://eu.i.posthog.com", map[string]any{ - "platform": "cli", - "os": "darwin", - }, func(apiKey string, config posthog.Config) (queueClient, error) { - return queue, nil - }) - require.NoError(t, err) - - err = client.Capture("device-1", EventCommandExecuted, map[string]any{ - "command": "login", - }, map[string]string{ - GroupProject: "proj_123", - }) - - require.NoError(t, err) - require.Len(t, queue.messages, 1) - msg, ok := queue.messages[0].(posthog.Capture) - require.True(t, ok) - assert.Equal(t, "device-1", msg.DistinctId) - assert.Equal(t, EventCommandExecuted, msg.Event) - assert.Equal(t, "cli", msg.Properties["platform"]) - assert.Equal(t, "darwin", msg.Properties["os"]) - assert.Equal(t, "login", msg.Properties["command"]) - assert.Equal(t, posthog.Groups{GroupProject: "proj_123"}, msg.Groups) -} - -func TestIdentifyAliasAndGroupIdentify(t *testing.T) { - queue := &fakeQueue{} - client, err := NewClient("phc_test", "", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { - return queue, nil - }) - require.NoError(t, err) - - require.NoError(t, client.Identify("user-123", map[string]any{"schema_version": 1})) - require.NoError(t, client.Alias("user-123", "device-123")) - require.NoError(t, client.GroupIdentify(GroupOrganization, "org_123", map[string]any{"slug": "acme"})) - require.NoError(t, client.Close()) - - require.Len(t, queue.messages, 3) - - identify, ok := queue.messages[0].(posthog.Identify) - require.True(t, ok) - assert.Equal(t, "user-123", identify.DistinctId) - assert.Equal(t, "cli", identify.Properties["platform"]) - assert.Equal(t, 1, identify.Properties["schema_version"]) - - alias, ok := queue.messages[1].(posthog.Alias) - require.True(t, ok) - assert.Equal(t, "user-123", alias.DistinctId) - assert.Equal(t, "device-123", alias.Alias) - - groupIdentify, ok := queue.messages[2].(posthog.GroupIdentify) - require.True(t, ok) - assert.Equal(t, GroupOrganization, groupIdentify.Type) - assert.Equal(t, "org_123", groupIdentify.Key) - assert.Equal(t, "cli", groupIdentify.Properties["platform"]) - assert.Equal(t, "acme", groupIdentify.Properties["slug"]) - assert.True(t, queue.closed) -} diff --git a/apps/cli-go/internal/telemetry/events.go b/apps/cli-go/internal/telemetry/events.go deleted file mode 100644 index 2ee362dc17..0000000000 --- a/apps/cli-go/internal/telemetry/events.go +++ /dev/null @@ -1,184 +0,0 @@ -package telemetry - -import "context" - -// CLI telemetry catalog. -// -// This file is the single place to review what analytics events the CLI sends -// and what metadata may be attached to them. Comments live next to the event, -// property, group, or signal definition they describe so the catalog is easy to -// scan without reading the rest of the implementation. -const ( - // - EventCommandExecuted: sent after a CLI command finishes, whether it - // succeeds or fails. This helps measure command usage, failure rates, and - // runtime. Event-specific properties are PropExitCode (process exit code), - // PropDurationMs (command runtime in milliseconds), and PropOutputFormat - // (the resolved output format the command used). Related groups: none - // added directly by this event. - EventCommandExecuted = "cli_command_executed" - // - EventProjectLinked: sent after the local CLI directory is linked to a - // Supabase project. This helps measure project-linking adoption and connect - // future events to the right project and organization. Event-specific - // properties: none. Related groups: GroupOrganization and GroupProject. - // Related group-identify payloads sent during linking are: - // organization group -> organization_slug, and project group -> name, - // organization_slug. - EventProjectLinked = "cli_project_linked" - // - EventLoginCompleted: sent after a login flow completes successfully. This - // helps measure successful login completion and supports identity stitching - // between anonymous and authenticated usage. Event-specific properties: - // none. Related groups: none added directly by this event. - EventLoginCompleted = "cli_login_completed" - // - EventStackStarted: sent after the local development stack starts - // successfully. This helps measure local development usage and successful - // stack startup. Event-specific properties: none. Related groups: none - // added directly by this event, but linked project groups may still be - // attached when available. - EventStackStarted = "cli_stack_started" - // - EventUpgradeSuggested: sent when a CLI command hits a plan-gated - // feature and displays a billing upgrade link. This helps identify - // which plan gates users encounter most often so we can improve - // error messages and documentation. Event-specific properties are - // PropFeatureKey (the entitlement key that was gated) and PropOrgSlug - // (the organization slug, empty if lookup failed). - EventUpgradeSuggested = "cli_upgrade_suggested" -) - -// Properties specific to EventUpgradeSuggested. -const ( - // PropFeatureKey is the entitlement key that triggered the upgrade suggestion. - PropFeatureKey = "feature_key" - // PropOrgSlug is the organization slug associated with the project. - PropOrgSlug = "org_slug" -) - -// TrackUpgradeSuggested fires an EventUpgradeSuggested telemetry event. -// Safe to call with any context; no-ops when telemetry is not configured. -func TrackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) { - if svc := FromContext(ctx); svc != nil { - _ = svc.Capture(ctx, EventUpgradeSuggested, map[string]any{ - PropFeatureKey: featureKey, - PropOrgSlug: orgSlug, - }, nil) - } -} - -// Shared event properties added to every captured event by Service.Capture. -const ( - // PropPlatform identifies the product source for the event. The CLI always - // sends "cli". - PropPlatform = "platform" - // PropSchemaVersion is the version of the telemetry payload format. This is - // not a database schema version. - PropSchemaVersion = "schema_version" - // PropDeviceID is an anonymous identifier for this CLI installation on this - // machine. - PropDeviceID = "device_id" - // PropSessionID is the PostHog session identifier used to group activity from - // one CLI session together. - PropSessionID = "$session_id" - // PropIsFirstRun is true when the current telemetry state was created during - // this run, which helps distinguish first-time setup from repeat usage. - PropIsFirstRun = "is_first_run" - // PropIsTTY is true when stdout is attached to an interactive terminal. - PropIsTTY = "is_tty" - // PropIsCI is true when the CLI appears to be running in a CI environment. - PropIsCI = "is_ci" - // PropIsAgent is true when the CLI appears to be running under an AI agent or - // automation tool. - PropIsAgent = "is_agent" - // PropOS is the operating system reported by the Go runtime. - PropOS = "os" - // PropArch is the CPU architecture reported by the Go runtime. - PropArch = "arch" - // PropCLIVersion is the version string of the CLI build that sent the event. - PropCLIVersion = "cli_version" - // PropEnvSignals is an optional summary of coarse environment hints. It is - // not a raw dump of environment variables. - PropEnvSignals = "env_signals" - // PropCommandRunID identifies one command invocation and can be used to tie - // together telemetry emitted during a single command run. - PropCommandRunID = "command_run_id" - // PropCommand is the normalized command path, such as "link" or "db push". - PropCommand = "command" - // PropFlags contains changed CLI flags for that command run. Safe flag values - // may be included, while sensitive values are redacted in the command - // telemetry implementation. - PropFlags = "flags" - // PropExitCode is the process exit code for the command that produced the - // event. - PropExitCode = "exit_code" - // PropDurationMs is the command runtime in milliseconds. - PropDurationMs = "duration_ms" - // PropOutputFormat is the resolved output format the command used (for - // example "pretty", "json", "yaml"), after agent auto-detection and any - // command-local override are applied. - PropOutputFormat = "output_format" -) - -// Group identifiers associate events with higher-level entities in PostHog. -const ( - // GroupOrganization identifies the Supabase organization related to the - // event. - GroupOrganization = "organization" - // GroupProject identifies the Supabase project related to the event. - GroupProject = "project" -) - -var ( - // EnvSignalPresenceKeys lists environment variables whose presence is recorded - // as true inside the "env_signals" property. - EnvSignalPresenceKeys = [...]string{ - // AI tools signals - "CURSOR_AGENT", - "CURSOR_TRACE_ID", - "GEMINI_CLI", - "CODEX_SANDBOX", - "CODEX_CI", - "CODEX_THREAD_ID", - "ANTIGRAVITY_AGENT", - "AUGMENT_AGENT", - "OPENCODE_CLIENT", - "CLAUDECODE", - "CLAUDE_CODE", - "REPL_ID", - "COPILOT_MODEL", - "COPILOT_ALLOW_ALL", - "COPILOT_GITHUB_TOKEN", - // CI signals - "CI", - "GITHUB_ACTIONS", - "BUILDKITE", - "TF_BUILD", - "JENKINS_URL", - "GITLAB_CI", - // Extra signals - "GITHUB_TOKEN", - "GITHUB_HEAD_REF", - "BITBUCKET_CLONE_DIR", - // Supabase environment signals - "SUPABASE_ACCESS_TOKEN", - "SUPABASE_HOME", - "SYSTEMROOT", - "SUPABASE_SSL_DEBUG", - "SUPABASE_CA_SKIP_VERIFY", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "NPM_CONFIG_REGISTRY", - "SUPABASE_SERVICE_ROLE_KEY", - "SUPABASE_PROJECT_ID", - "SUPABASE_POSTGRES_URL", - "SUPABASE_ENV", - } - - // EnvSignalValueKeys lists environment variables whose trimmed values may be - // recorded inside the "env_signals" property. - EnvSignalValueKeys = [...]string{ - "AI_AGENT", - "CURSOR_EXTENSION_HOST_ROLE", - "TERM", - "TERM_PROGRAM", - "TERM_PROGRAM_VERSION", - "TERM_COLOR_MODE", - } -) diff --git a/apps/cli-go/internal/telemetry/project.go b/apps/cli-go/internal/telemetry/project.go deleted file mode 100644 index 8fe77a2096..0000000000 --- a/apps/cli-go/internal/telemetry/project.go +++ /dev/null @@ -1,125 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/config" -) - -type LinkedProject struct { - Ref string `json:"ref"` - Name string `json:"name"` - OrganizationID string `json:"organization_id"` - OrganizationSlug string `json:"organization_slug"` -} - -// linkedProjectEnv carries the linked project as the JSON of LinkedProject when the TypeScript -// CLI keeps the link outside supabase/.temp. -const linkedProjectEnv = "SUPABASE_INTERNAL_LINKED_PROJECT" - -func linkedProjectPath() string { - return filepath.Join(utils.TempDir, "linked-project.json") -} - -func SaveLinkedProject(project api.V1ProjectWithDatabaseResponseOutput, fsys afero.Fs) error { - linked := LinkedProject{ - Ref: project.Ref, - Name: project.Name, - OrganizationID: project.OrganizationId, - OrganizationSlug: project.OrganizationSlug, - } - contents, err := json.Marshal(linked) - if err != nil { - return errors.Errorf("failed to encode linked project: %w", err) - } - return utils.WriteFile(linkedProjectPath(), contents, fsys) -} - -func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { - if config.UsesStackState() { - contents := os.Getenv(linkedProjectEnv) - if contents == "" { - return LinkedProject{}, os.ErrNotExist - } - return parseLinkedProject([]byte(contents)) - } - contents, err := afero.ReadFile(fsys, linkedProjectPath()) - if err != nil { - return LinkedProject{}, err - } - return parseLinkedProject(contents) -} - -func parseLinkedProject(contents []byte) (LinkedProject, error) { - var linked LinkedProject - if err := json.Unmarshal(contents, &linked); err != nil { - return LinkedProject{}, errors.Errorf("failed to parse linked project: %w", err) - } - return linked, nil -} - -// HasLinkedProject reports whether a cached linked-project.json exists. -func HasLinkedProject(fsys afero.Fs) bool { - _, err := LoadLinkedProject(fsys) - return err == nil -} - -// CacheProjectAndIdentifyGroups writes project metadata to linked-project.json -// and fires GroupIdentify for the org and project so PostHog has group metadata. -// This matches the behavior of the `supabase link` flow. -// -// The caller is responsible for fetching the project from the API and checking -// auth — this function only handles caching and PostHog group identification. -// -// Best-effort: logs errors to debug output, never returns them. -func CacheProjectAndIdentifyGroups(project api.V1ProjectWithDatabaseResponseOutput, service *Service, fsys afero.Fs) { - if err := SaveLinkedProject(project, fsys); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - if service == nil { - return - } - if project.OrganizationId != "" { - if err := service.GroupIdentify(GroupOrganization, project.OrganizationId, map[string]any{ - "organization_slug": project.OrganizationSlug, - }); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - } - if project.Ref != "" { - if err := service.GroupIdentify(GroupProject, project.Ref, map[string]any{ - "name": project.Name, - "organization_slug": project.OrganizationSlug, - }); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) - } - } -} - -func linkedProjectGroups(fsys afero.Fs) map[string]string { - linked, err := LoadLinkedProject(fsys) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return nil - } - groups := make(map[string]string, 2) - if linked.OrganizationID != "" { - groups[GroupOrganization] = linked.OrganizationID - } - if linked.Ref != "" { - groups[GroupProject] = linked.Ref - } - if len(groups) == 0 { - return nil - } - return groups -} diff --git a/apps/cli-go/internal/telemetry/project_test.go b/apps/cli-go/internal/telemetry/project_test.go deleted file mode 100644 index 2cf1fa89ce..0000000000 --- a/apps/cli-go/internal/telemetry/project_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package telemetry - -import ( - "os" - "testing" - "time" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/config" -) - -var testProject = api.V1ProjectWithDatabaseResponseOutput{ - Ref: "proj_abc", - Name: "My Project", - OrganizationId: "org_123", - OrganizationSlug: "acme", -} - -func newTestService(t *testing.T, fsys afero.Fs, analytics *fakeAnalytics) *Service { - t.Helper() - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC) }, - }) - require.NoError(t, err) - return service -} - -func TestHasLinkedProject(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - - t.Run("false when no cache", func(t *testing.T) { - fsys := afero.NewMemMapFs() - assert.False(t, HasLinkedProject(fsys)) - }) - - t.Run("true when cache exists", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(testProject, fsys)) - assert.True(t, HasLinkedProject(fsys)) - }) -} - -func TestCacheProjectAndIdentifyGroups(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - - t.Run("writes cache file", func(t *testing.T) { - fsys := afero.NewMemMapFs() - CacheProjectAndIdentifyGroups(testProject, nil, fsys) - - linked, err := LoadLinkedProject(fsys) - require.NoError(t, err) - assert.Equal(t, "proj_abc", linked.Ref) - assert.Equal(t, "org_123", linked.OrganizationID) - assert.Equal(t, "acme", linked.OrganizationSlug) - assert.Equal(t, "My Project", linked.Name) - }) - - t.Run("fires GroupIdentify for org and project", func(t *testing.T) { - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - service := newTestService(t, fsys, analytics) - - CacheProjectAndIdentifyGroups(testProject, service, fsys) - - require.Len(t, analytics.groupIdentifies, 2) - - orgCall := analytics.groupIdentifies[0] - assert.Equal(t, GroupOrganization, orgCall.groupType) - assert.Equal(t, "org_123", orgCall.groupKey) - assert.Equal(t, "acme", orgCall.properties["organization_slug"]) - - projCall := analytics.groupIdentifies[1] - assert.Equal(t, GroupProject, projCall.groupType) - assert.Equal(t, "proj_abc", projCall.groupKey) - assert.Equal(t, "My Project", projCall.properties["name"]) - assert.Equal(t, "acme", projCall.properties["organization_slug"]) - }) - - t.Run("skips GroupIdentify when service is nil", func(t *testing.T) { - fsys := afero.NewMemMapFs() - CacheProjectAndIdentifyGroups(testProject, nil, fsys) - - // Cache should still be written - linked, err := LoadLinkedProject(fsys) - require.NoError(t, err) - assert.Equal(t, "proj_abc", linked.Ref) - }) - - t.Run("skips GroupIdentify for empty org ID", func(t *testing.T) { - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - service := newTestService(t, fsys, analytics) - - noOrgProject := api.V1ProjectWithDatabaseResponseOutput{ - Ref: "proj_abc", - Name: "My Project", - } - CacheProjectAndIdentifyGroups(noOrgProject, service, fsys) - - // Only project GroupIdentify, no org - require.Len(t, analytics.groupIdentifies, 1) - assert.Equal(t, GroupProject, analytics.groupIdentifies[0].groupType) - }) -} - -func TestLinkedProjectGroups(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - - t.Run("returns nil when no cache", func(t *testing.T) { - fsys := afero.NewMemMapFs() - groups := linkedProjectGroups(fsys) - assert.Nil(t, groups) - }) - - t.Run("returns groups from cache", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(testProject, fsys)) - groups := linkedProjectGroups(fsys) - assert.Equal(t, map[string]string{ - GroupOrganization: "org_123", - GroupProject: "proj_abc", - }, groups) - }) - - t.Run("reads the linked project from the env in stack mode", func(t *testing.T) { - t.Setenv(config.StackStateEnv, "1") - t.Setenv(linkedProjectEnv, `{"ref":"proj_abc","name":"n","organization_id":"org_123","organization_slug":"acme"}`) - fsys := afero.NewMemMapFs() - // The legacy cache must stay unread. - require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponseOutput{Ref: "stale", OrganizationId: "stale"}, fsys)) - assert.Equal(t, map[string]string{ - GroupOrganization: "org_123", - GroupProject: "proj_abc", - }, linkedProjectGroups(fsys)) - assert.True(t, HasLinkedProject(fsys)) - }) - - t.Run("reads as unlinked in stack mode without the env", func(t *testing.T) { - t.Setenv(config.StackStateEnv, "1") - t.Setenv(linkedProjectEnv, "") - fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(testProject, fsys)) - _, err := LoadLinkedProject(fsys) - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Nil(t, linkedProjectGroups(fsys)) - }) -} diff --git a/apps/cli-go/internal/telemetry/service.go b/apps/cli-go/internal/telemetry/service.go deleted file mode 100644 index ced8a8ee01..0000000000 --- a/apps/cli-go/internal/telemetry/service.go +++ /dev/null @@ -1,294 +0,0 @@ -package telemetry - -import ( - "context" - "os" - "runtime" - "time" - - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" -) - -type commandContextKey struct{} -type serviceContextKey struct{} - -type CommandContext struct { - RunID string - Command string - Flags map[string]any - Groups map[string]string -} - -type Options struct { - Analytics Analytics - Now func() time.Time - IsTTY bool - IsCI bool - IsAgent bool - EnvSignals map[string]any - CLIName string - GOOS string - GOARCH string -} - -type Service struct { - fsys afero.Fs - analytics Analytics - now func() time.Time - state State - userID string - isFirstRun bool - isTTY bool - isCI bool - isAgent bool - envSignals map[string]any - cliVersion string - goos string - goarch string -} - -func NewService(fsys afero.Fs, opts Options) (*Service, error) { - now := opts.Now - if now == nil { - now = time.Now - } - state, created, err := LoadOrCreateState(fsys, now()) - if err != nil { - return nil, err - } - analytics := opts.Analytics - if analytics == nil { - analytics, err = NewClient(utils.PostHogAPIKey, utils.PostHogEndpoint, nil, nil) - if err != nil { - return nil, err - } - } - cliVersion := opts.CLIName - if cliVersion == "" { - cliVersion = utils.Version - } - goos := opts.GOOS - if goos == "" { - goos = runtime.GOOS - } - goarch := opts.GOARCH - if goarch == "" { - goarch = runtime.GOARCH - } - return &Service{ - fsys: fsys, - analytics: analytics, - now: now, - state: state, - isFirstRun: created, - isTTY: opts.IsTTY, - isCI: opts.IsCI, - isAgent: opts.IsAgent, - envSignals: opts.EnvSignals, - cliVersion: cliVersion, - goos: goos, - goarch: goarch, - }, nil -} - -func WithCommandContext(ctx context.Context, cmd CommandContext) context.Context { - return context.WithValue(ctx, commandContextKey{}, cmd) -} - -func WithService(ctx context.Context, service *Service) context.Context { - return context.WithValue(ctx, serviceContextKey{}, service) -} - -func FromContext(ctx context.Context) *Service { - if ctx == nil { - return nil - } - service, _ := ctx.Value(serviceContextKey{}).(*Service) - return service -} - -// Property catalog: see events.go. -func (s *Service) Capture(ctx context.Context, event string, properties map[string]any, groups map[string]string) error { - if !s.canSend() { - return nil - } - mergedProperties := s.baseProperties() - command := commandContextFrom(ctx) - if command.RunID != "" { - mergedProperties[PropCommandRunID] = command.RunID - } - if command.Command != "" { - mergedProperties[PropCommand] = command.Command - } - if command.Flags != nil { - mergedProperties[PropFlags] = command.Flags - } - for key, value := range properties { - mergedProperties[key] = value - } - return s.analytics.Capture(s.distinctID(), event, mergedProperties, mergeGroups(linkedProjectGroups(s.fsys), mergeGroups(command.Groups, groups))) -} - -// StitchLogin records the authenticated user as the identity for all -// subsequent captures in this process. In persistent runtimes it also merges -// the device's pre-login history via $create_alias and persists the identity -// for future runs; ephemeral runtimes get the in-memory stamp only. -// See docs/adr/0013-hybrid-stitch-stamp-identity-attribution.md. -func (s *Service) StitchLogin(distinctID string) error { - if s == nil { - return nil - } - // Alias only the first identity this device ever sees. Re-aliasing on - // re-login (or on the login command's call after the response hook has - // already stitched) would merge a second user into the device's existing - // person graph in PostHog. - firstIdentity := s.state.DistinctID == "" - s.userID = distinctID - if s.isEphemeralIdentityRuntime() { - return nil - } - if firstIdentity && s.canSend() { - if err := s.analytics.Alias(distinctID, s.state.DeviceID); err != nil { - // Leave the identity re-stitchable without dropping the in-memory - // stamp: nothing was enqueued, so a retry (e.g. the login command - // after the response hook errored) must still qualify as the first - // identity, but captures in this process should remain attributed. - return err - } - } - s.state.DistinctID = distinctID - return SaveState(s.state, s.fsys) -} - -// ObserveAuthenticatedUser records who the current process is authenticated -// as. First-time identities get the full stitch; when an identity already -// exists (e.g. telemetry.json holds a previous user but the active token -// belongs to another), only the in-memory stamp is updated — re-aliasing the -// device to a second user would merge unrelated person graphs in PostHog. -func (s *Service) ObserveAuthenticatedUser(distinctID string) error { - if s == nil { - return nil - } - if s.NeedsIdentityStitch() { - return s.StitchLogin(distinctID) - } - s.userID = distinctID - return nil -} - -// ResetIdentity severs the link between this device and the logged-out user: -// the identity is forgotten and the device ID is rotated, so a later login as -// a different account aliases a fresh device instead of one already merged -// into the previous user's person graph. Logout-only — transient failure -// paths use ClearDistinctID, which keeps the device ID. -func (s *Service) ResetIdentity() error { - if s == nil { - return nil - } - s.userID = "" - s.state.DistinctID = "" - s.state.DeviceID = uuid.NewString() - return SaveState(s.state, s.fsys) -} - -func (s *Service) ClearDistinctID() error { - if s == nil { - return nil - } - s.userID = "" - s.state.DistinctID = "" - return SaveState(s.state, s.fsys) -} - -func (s *Service) NeedsIdentityStitch() bool { - return s != nil && s.userID == "" && s.state.DistinctID == "" && s.canSend() -} - -func (s *Service) isEphemeralIdentityRuntime() bool { - return s.isCI || (s.isFirstRun && !s.isTTY) -} - -func (s *Service) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { - if !s.canSend() { - return nil - } - return s.analytics.GroupIdentify(groupType, groupKey, s.basePropertiesWith(properties)) -} - -func (s *Service) Close() error { - if s == nil || s.analytics == nil { - return nil - } - return s.analytics.Close() -} - -func (s *Service) baseProperties() map[string]any { - properties := map[string]any{ - PropPlatform: "cli", - PropSchemaVersion: s.state.SchemaVersion, - PropDeviceID: s.state.DeviceID, - PropSessionID: s.state.SessionID, - PropIsFirstRun: s.isFirstRun, - PropIsTTY: s.isTTY, - PropIsCI: s.isCI, - PropIsAgent: s.isAgent, - PropOS: s.goos, - PropArch: s.goarch, - PropCLIVersion: s.cliVersion, - } - if len(s.envSignals) > 0 { - properties[PropEnvSignals] = s.envSignals - } - return properties -} - -func (s *Service) basePropertiesWith(properties map[string]any) map[string]any { - merged := s.baseProperties() - for key, value := range properties { - merged[key] = value - } - return merged -} - -func (s *Service) distinctID() string { - if s.userID != "" { - return s.userID - } - if s.state.DistinctID != "" { - return s.state.DistinctID - } - return s.state.DeviceID -} - -func commandContextFrom(ctx context.Context) CommandContext { - if ctx == nil { - return CommandContext{} - } - cmd, _ := ctx.Value(commandContextKey{}).(CommandContext) - return cmd -} - -func mergeGroups(existing map[string]string, extra map[string]string) map[string]string { - if len(existing) == 0 && len(extra) == 0 { - return nil - } - merged := make(map[string]string, len(existing)+len(extra)) - for key, value := range existing { - merged[key] = value - } - for key, value := range extra { - merged[key] = value - } - return merged -} - -func (s *Service) canSend() bool { - return s != nil && - s.analytics != nil && - s.analytics.Enabled() && - s.state.Enabled && - os.Getenv("DO_NOT_TRACK") != "1" && - os.Getenv("SUPABASE_TELEMETRY_DISABLED") != "1" -} diff --git a/apps/cli-go/internal/telemetry/service_test.go b/apps/cli-go/internal/telemetry/service_test.go deleted file mode 100644 index e2fe7b2fda..0000000000 --- a/apps/cli-go/internal/telemetry/service_test.go +++ /dev/null @@ -1,570 +0,0 @@ -package telemetry - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/api" -) - -type captureCall struct { - distinctID string - event string - properties map[string]any - groups map[string]string -} - -type identifyCall struct { - distinctID string - properties map[string]any -} - -type aliasCall struct { - distinctID string - alias string -} - -type groupIdentifyCall struct { - groupType string - groupKey string - properties map[string]any -} - -type fakeAnalytics struct { - enabled bool - captures []captureCall - identifies []identifyCall - aliases []aliasCall - groupIdentifies []groupIdentifyCall - aliasErr error - closed bool -} - -func (f *fakeAnalytics) Enabled() bool { return f.enabled } - -func (f *fakeAnalytics) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { - f.captures = append(f.captures, captureCall{distinctID: distinctID, event: event, properties: properties, groups: groups}) - return nil -} - -func (f *fakeAnalytics) Identify(distinctID string, properties map[string]any) error { - f.identifies = append(f.identifies, identifyCall{distinctID: distinctID, properties: properties}) - return nil -} - -func (f *fakeAnalytics) Alias(distinctID string, alias string) error { - if f.aliasErr != nil { - err := f.aliasErr - f.aliasErr = nil - return err - } - f.aliases = append(f.aliases, aliasCall{distinctID: distinctID, alias: alias}) - return nil -} - -func (f *fakeAnalytics) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { - f.groupIdentifies = append(f.groupIdentifies, groupIdentifyCall{groupType: groupType, groupKey: groupKey, properties: properties}) - return nil -} - -func (f *fakeAnalytics) Close() error { - f.closed = true - return nil -} - -func TestServiceCaptureIncludesBasePropertiesAndCommandContext(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - IsCI: true, - IsAgent: true, - EnvSignals: map[string]any{ - "CLAUDE_CODE": true, - "TERM_PROGRAM": "iTerm.app", - }, - CLIName: "1.2.3", - GOOS: "darwin", - GOARCH: "arm64", - }) - require.NoError(t, err) - - ctx := WithCommandContext(context.Background(), CommandContext{ - RunID: "run-123", - Command: "login", - Flags: map[string]any{ - "token": "", - }, - }) - - require.NoError(t, service.Capture(ctx, EventCommandExecuted, map[string]any{ - PropDurationMs: 42, - }, nil)) - - require.Len(t, analytics.captures, 1) - call := analytics.captures[0] - assert.NoError(t, uuid.Validate(call.distinctID)) - assert.Equal(t, EventCommandExecuted, call.event) - assert.Equal(t, "cli", call.properties[PropPlatform]) - assert.Equal(t, SchemaVersion, call.properties[PropSchemaVersion]) - assert.Equal(t, true, call.properties[PropIsFirstRun]) - assert.Equal(t, true, call.properties[PropIsTTY]) - assert.Equal(t, true, call.properties[PropIsCI]) - assert.Equal(t, true, call.properties[PropIsAgent]) - assert.Equal(t, map[string]any{ - "CLAUDE_CODE": true, - "TERM_PROGRAM": "iTerm.app", - }, call.properties[PropEnvSignals]) - assert.Equal(t, "darwin", call.properties[PropOS]) - assert.Equal(t, "arm64", call.properties[PropArch]) - assert.Equal(t, "1.2.3", call.properties[PropCLIVersion]) - assert.Equal(t, "run-123", call.properties[PropCommandRunID]) - assert.Equal(t, "login", call.properties[PropCommand]) - assert.Equal(t, map[string]any{"token": ""}, call.properties[PropFlags]) - _, hasFlagsUsed := call.properties["flags_used"] - assert.False(t, hasFlagsUsed) - _, hasFlagValues := call.properties["flag_values"] - assert.False(t, hasFlagValues) - assert.Equal(t, 42, call.properties[PropDurationMs]) -} - -func TestServiceStitchLoginPersistsDistinctID(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - deviceID := service.state.DeviceID - - require.NoError(t, service.StitchLogin("user-123")) - require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) - - require.Len(t, analytics.aliases, 1) - assert.Equal(t, "user-123", analytics.aliases[0].distinctID) - assert.Equal(t, deviceID, analytics.aliases[0].alias) - assert.Empty(t, analytics.identifies) - require.Len(t, analytics.captures, 1) - assert.Equal(t, "user-123", analytics.captures[0].distinctID) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "user-123", state.DistinctID) -} - -func TestServiceStitchLoginInEphemeralRuntimeStampsWithoutPersisting(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsCI: true, - }) - require.NoError(t, err) - - require.NoError(t, service.StitchLogin("user-123")) - require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) - - require.Len(t, analytics.captures, 1) - assert.Equal(t, "user-123", analytics.captures[0].distinctID) - assert.Empty(t, analytics.aliases) - assert.Empty(t, analytics.identifies) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Empty(t, state.DistinctID) -} - -func TestServiceObserveAuthenticatedUser(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - - t.Run("stamps over a stale persisted identity without alias or state write", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - require.NoError(t, SaveState(State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - DistinctID: "old-user", - }, fsys)) - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - require.NoError(t, service.ObserveAuthenticatedUser("new-user")) - require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) - - assert.Empty(t, analytics.aliases) - assert.Empty(t, analytics.identifies) - require.Len(t, analytics.captures, 1) - assert.Equal(t, "new-user", analytics.captures[0].distinctID) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "old-user", state.DistinctID) - }) - - t.Run("performs the full stitch when no identity exists yet", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - require.NoError(t, service.ObserveAuthenticatedUser("user-123")) - - require.Len(t, analytics.aliases, 1) - assert.Equal(t, "user-123", analytics.aliases[0].distinctID) - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "user-123", state.DistinctID) - }) -} - -func TestServiceStitchLoginReloginDoesNotRealias(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - require.NoError(t, SaveState(State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - DistinctID: "user-a", - }, fsys)) - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - require.NoError(t, service.StitchLogin("user-b")) - - assert.Empty(t, analytics.aliases) - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "user-b", state.DistinctID) - - require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) - require.Len(t, analytics.captures, 1) - assert.Equal(t, "user-b", analytics.captures[0].distinctID) -} - -func TestServiceStitchLoginIsIdempotentWithinProcess(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - // The response hook stitches first; the login command then calls - // StitchLogin directly with the same id. One alias total. - require.NoError(t, service.ObserveAuthenticatedUser("user-123")) - require.NoError(t, service.StitchLogin("user-123")) - - require.Len(t, analytics.aliases, 1) -} - -func TestServiceResetIdentityRotatesDeviceID(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - require.NoError(t, service.StitchLogin("user-a")) - require.Len(t, analytics.aliases, 1) - oldDeviceID := analytics.aliases[0].alias - - require.NoError(t, service.ResetIdentity()) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Empty(t, state.DistinctID) - assert.NotEqual(t, oldDeviceID, state.DeviceID) - assert.NoError(t, uuid.Validate(state.DeviceID)) - - // A later login as another user aliases the fresh device id, so the old - // user's person graph is never touched. - require.NoError(t, service.StitchLogin("user-b")) - require.Len(t, analytics.aliases, 2) - assert.Equal(t, state.DeviceID, analytics.aliases[1].alias) - - require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) - assert.Equal(t, "user-b", analytics.captures[len(analytics.captures)-1].distinctID) -} - -func TestServiceStitchLoginRetriesAliasAfterEnqueueFailure(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true, aliasErr: assert.AnError} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - require.Error(t, service.StitchLogin("user-123")) - require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) - require.Len(t, analytics.captures, 1) - assert.Equal(t, "user-123", analytics.captures[0].distinctID) - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Empty(t, state.DistinctID) - - // The failed attempt must not poison the first-identity gate: a retry - // (e.g. the login command after the response hook errored) still aliases. - require.NoError(t, service.StitchLogin("user-123")) - require.Len(t, analytics.aliases, 1) - state, err = LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "user-123", state.DistinctID) -} - -func TestServiceCapturePrefersInMemoryUserIDOverPersistedDistinctID(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - require.NoError(t, SaveState(State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - DistinctID: "old-user", - }, fsys)) - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsCI: true, - }) - require.NoError(t, err) - - require.NoError(t, service.StitchLogin("new-user")) - require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) - - require.Len(t, analytics.captures, 1) - assert.Equal(t, "new-user", analytics.captures[0].distinctID) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, "old-user", state.DistinctID) -} - -func TestServiceClearDistinctIDFallsBackToDeviceID(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - deviceID := service.state.DeviceID - require.NoError(t, service.StitchLogin("user-123")) - - require.NoError(t, service.ClearDistinctID()) - require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) - - require.Len(t, analytics.captures, 1) - assert.Equal(t, deviceID, analytics.captures[0].distinctID) - - state, err := LoadState(fsys) - require.NoError(t, err) - assert.Empty(t, state.DistinctID) -} - -func TestServiceCaptureIncludesLinkedProjectGroups(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponseOutput{ - Ref: "proj_123", - Name: "My Project", - OrganizationId: "org_123", - OrganizationSlug: "acme", - }, fsys)) - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - - require.NoError(t, service.Capture(context.Background(), EventStackStarted, nil, nil)) - - require.Len(t, analytics.captures, 1) - assert.Equal(t, map[string]string{ - GroupOrganization: "org_123", - GroupProject: "proj_123", - }, analytics.captures[0].groups) -} - -func TestServiceNeedsIdentityStitch(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - IsTTY: true, - }) - require.NoError(t, err) - - t.Run("true when DistinctID is empty", func(t *testing.T) { - assert.True(t, service.NeedsIdentityStitch()) - }) - - t.Run("false after StitchLogin", func(t *testing.T) { - require.NoError(t, service.StitchLogin("user-123")) - assert.False(t, service.NeedsIdentityStitch()) - }) - - t.Run("true in CI with empty DistinctID so capture stamping can start", func(t *testing.T) { - ciFsys := afero.NewMemMapFs() - ciService, err := NewService(ciFsys, Options{ - Analytics: &fakeAnalytics{enabled: true}, - Now: func() time.Time { return now }, - IsCI: true, - }) - require.NoError(t, err) - assert.True(t, ciService.NeedsIdentityStitch()) - }) - - t.Run("false after StitchLogin in ephemeral runtime despite nothing persisted", func(t *testing.T) { - ephemeralFsys := afero.NewMemMapFs() - ephemeralService, err := NewService(ephemeralFsys, Options{ - Analytics: &fakeAnalytics{enabled: true}, - Now: func() time.Time { return now }, - IsCI: true, - }) - require.NoError(t, err) - require.NoError(t, ephemeralService.StitchLogin("user-123")) - assert.False(t, ephemeralService.NeedsIdentityStitch()) - }) - - t.Run("true in first-run non-TTY runtime", func(t *testing.T) { - ephemeralFsys := afero.NewMemMapFs() - ephemeralService, err := NewService(ephemeralFsys, Options{ - Analytics: &fakeAnalytics{enabled: true}, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - assert.True(t, ephemeralService.NeedsIdentityStitch()) - }) - - t.Run("true in persisted non-TTY runtime", func(t *testing.T) { - persistedFsys := afero.NewMemMapFs() - require.NoError(t, SaveState(State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - }, persistedFsys)) - - persistedService, err := NewService(persistedFsys, Options{ - Analytics: &fakeAnalytics{enabled: true}, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - assert.True(t, persistedService.NeedsIdentityStitch()) - }) -} - -func TestServiceCaptureHonorsConsentAndEnvOptOut(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - - t.Run("disabled telemetry file suppresses capture", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - require.NoError(t, SaveState(State{ - Enabled: false, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - }, fsys)) - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - - require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) - assert.Empty(t, analytics.captures) - }) - - t.Run("DO_NOT_TRACK suppresses capture", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - t.Setenv("DO_NOT_TRACK", "1") - fsys := afero.NewMemMapFs() - analytics := &fakeAnalytics{enabled: true} - - service, err := NewService(fsys, Options{ - Analytics: analytics, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - - require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) - assert.Empty(t, analytics.captures) - }) -} diff --git a/apps/cli-go/internal/telemetry/state.go b/apps/cli-go/internal/telemetry/state.go deleted file mode 100644 index 3808abb78a..0000000000 --- a/apps/cli-go/internal/telemetry/state.go +++ /dev/null @@ -1,192 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "os" - "path/filepath" - "time" - - "github.com/go-errors/errors" - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" -) - -const SchemaVersion = 1 - -const sessionRotationThreshold = 30 * time.Minute - -// errMalformedState marks any read where the file existed but couldn't be -// decoded into a State — covers JSON syntax errors, unexpected types -// (e.g. session_last_active stored as a number), and field-level unmarshal -// failures from time.Time / uuid. Used to trigger fresh-state creation. -var errMalformedState = errors.New("malformed telemetry state") - -type State struct { - Enabled bool `json:"enabled"` - DeviceID string `json:"device_id"` - SessionID string `json:"session_id"` - SessionLastActive time.Time `json:"session_last_active"` - DistinctID string `json:"distinct_id,omitempty"` - SchemaVersion int `json:"schema_version"` -} - -type rawState struct { - Enabled *bool `json:"enabled"` - Consent *string `json:"consent"` - DeviceID string `json:"device_id"` - SessionID string `json:"session_id"` - SessionLastActive json.RawMessage `json:"session_last_active"` - DistinctID string `json:"distinct_id,omitempty"` - SchemaVersion int `json:"schema_version"` -} - -func telemetryPath() (string, error) { - home, err := utils.SupabaseHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, "telemetry.json"), nil -} - -func parseConsent(raw rawState) (bool, bool, error) { - if raw.Consent != nil { - switch *raw.Consent { - case "granted": - return true, true, nil - case "denied": - return false, true, nil - default: - return false, false, errors.Errorf("%w: invalid consent", errMalformedState) - } - } - if raw.Enabled == nil { - return false, false, errors.Errorf("%w: missing enabled", errMalformedState) - } - return *raw.Enabled, false, nil -} - -func parseSessionLastActive(raw json.RawMessage, allowUnixMillis bool) (time.Time, error) { - var text string - if err := json.Unmarshal(raw, &text); err == nil { - parsed, err := time.Parse(time.RFC3339Nano, text) - if err != nil { - return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) - } - return parsed, nil - } - if allowUnixMillis { - var millis int64 - if err := json.Unmarshal(raw, &millis); err == nil { - return time.UnixMilli(millis).UTC(), nil - } - } - return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) -} - -func decodeState(contents []byte) (State, error) { - var raw rawState - if err := json.Unmarshal(contents, &raw); err != nil { - return State{}, errors.Errorf("%w: %v", errMalformedState, err) - } - enabled, allowUnixMillis, err := parseConsent(raw) - if err != nil { - return State{}, err - } - sessionLastActive, err := parseSessionLastActive(raw.SessionLastActive, allowUnixMillis) - if err != nil { - return State{}, err - } - if raw.DeviceID == "" || raw.SessionID == "" { - return State{}, errors.Errorf("%w: missing identity", errMalformedState) - } - schemaVersion := raw.SchemaVersion - if schemaVersion == 0 { - schemaVersion = SchemaVersion - } - return State{ - Enabled: enabled, - DeviceID: raw.DeviceID, - SessionID: raw.SessionID, - SessionLastActive: sessionLastActive, - DistinctID: raw.DistinctID, - SchemaVersion: schemaVersion, - }, nil -} - -func LoadState(fsys afero.Fs) (State, error) { - path, err := telemetryPath() - if err != nil { - return State{}, err - } - contents, err := afero.ReadFile(fsys, path) - if err != nil { - return State{}, err - } - return decodeState(contents) -} - -func SaveState(state State, fsys afero.Fs) error { - path, err := telemetryPath() - if err != nil { - return err - } - contents, err := json.Marshal(state) - if err != nil { - return errors.Errorf("failed to encode telemetry file: %w", err) - } - return utils.WriteFile(path, contents, fsys) -} - -func LoadOrCreateState(fsys afero.Fs, now time.Time) (State, bool, error) { - state, err := LoadState(fsys) - if err == nil { - if now.UTC().Sub(state.SessionLastActive) > sessionRotationThreshold { - state.SessionID = uuid.NewString() - } - state.SessionLastActive = now.UTC() - return state, false, SaveState(state, fsys) - } - // Treat a missing file OR an unparseable file as "no existing state" and - // recreate. Identity fields (device_id, session_id) are not worth - // surfacing an error for — losing them is harmless. We only propagate - // genuine I/O errors (permissions, disk full) so the user can act. - if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errMalformedState) { - return State{}, false, err - } - state = State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now.UTC(), - SchemaVersion: SchemaVersion, - } - return state, true, SaveState(state, fsys) -} - -func Disabled(fsys afero.Fs, now time.Time) (bool, error) { - if os.Getenv("DO_NOT_TRACK") == "1" { - return true, nil - } - if os.Getenv("SUPABASE_TELEMETRY_DISABLED") == "1" { - return true, nil - } - state, _, err := LoadOrCreateState(fsys, now) - if err != nil { - return false, err - } - return !state.Enabled, nil -} - -func SetEnabled(fsys afero.Fs, enabled bool, now time.Time) (State, error) { - state, _, err := LoadOrCreateState(fsys, now) - if err != nil { - return State{}, err - } - state.Enabled = enabled - return state, SaveState(state, fsys) -} - -func Status(fsys afero.Fs, now time.Time) (State, bool, error) { - return LoadOrCreateState(fsys, now) -} diff --git a/apps/cli-go/internal/telemetry/state_test.go b/apps/cli-go/internal/telemetry/state_test.go deleted file mode 100644 index 7ca3a178c6..0000000000 --- a/apps/cli-go/internal/telemetry/state_test.go +++ /dev/null @@ -1,289 +0,0 @@ -package telemetry - -import ( - "testing" - "time" - - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTelemetryPath(t *testing.T) { - t.Run("uses SUPABASE_HOME when set", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - t.Setenv("HOME", "/tmp/ignored-home") - - path, err := telemetryPath() - - require.NoError(t, err) - assert.Equal(t, "/tmp/supabase-home/telemetry.json", path) - }) - - t.Run("falls back to HOME/.supabase", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "") - t.Setenv("HOME", "/tmp/home") - - path, err := telemetryPath() - - require.NoError(t, err) - assert.Equal(t, "/tmp/home/.supabase/telemetry.json", path) - }) -} - -func TestLoadOrCreateState(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - - t.Run("creates default state and writes it", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.True(t, created) - assert.True(t, state.Enabled) - assert.Equal(t, SchemaVersion, state.SchemaVersion) - assert.Equal(t, now, state.SessionLastActive) - assert.Empty(t, state.DistinctID) - assert.NoError(t, uuid.Validate(state.DeviceID)) - assert.NoError(t, uuid.Validate(state.SessionID)) - - saved, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, state, saved) - }) - - t.Run("updates last active and preserves existing state", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - initial := State{ - Enabled: false, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now.Add(-10 * time.Minute), - DistinctID: "user-123", - SchemaVersion: SchemaVersion, - } - require.NoError(t, SaveState(initial, fsys)) - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.False(t, created) - assert.False(t, state.Enabled) - assert.Equal(t, initial.DeviceID, state.DeviceID) - assert.Equal(t, initial.SessionID, state.SessionID) - assert.Equal(t, "user-123", state.DistinctID) - assert.Equal(t, now, state.SessionLastActive) - }) - - t.Run("reads disabled TypeScript telemetry config", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - path, err := telemetryPath() - require.NoError(t, err) - require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) - require.NoError(t, afero.WriteFile( - fsys, - path, - []byte(`{"consent":"denied","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), - 0644, - )) - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.False(t, created) - assert.False(t, state.Enabled) - assert.Equal(t, "ts-device", state.DeviceID) - assert.Equal(t, "ts-session", state.SessionID) - assert.Equal(t, "user-123", state.DistinctID) - assert.Equal(t, SchemaVersion, state.SchemaVersion) - }) - - t.Run("reads enabled TypeScript telemetry config", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - path, err := telemetryPath() - require.NoError(t, err) - require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) - require.NoError(t, afero.WriteFile( - fsys, - path, - []byte(`{"consent":"granted","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), - 0644, - )) - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.False(t, created) - assert.True(t, state.Enabled) - assert.Equal(t, "ts-device", state.DeviceID) - assert.Equal(t, "ts-session", state.SessionID) - assert.Equal(t, "user-123", state.DistinctID) - assert.Equal(t, SchemaVersion, state.SchemaVersion) - }) - - t.Run("recovers from corrupted state file", func(t *testing.T) { - // Each entry simulates a real-world corruption shape we've observed. - corruptions := map[string][]byte{ - "empty file": {}, - "truncated json": []byte(`{"enabled":tru`), - "session_last_active is a number (not a string)": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":1776770348993,"schema_version":1}`), - "session_last_active is a malformed string": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":"not-a-time","schema_version":1}`), - } - for label, contents := range corruptions { - t.Run(label, func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - path, err := telemetryPath() - require.NoError(t, err) - require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) - require.NoError(t, afero.WriteFile(fsys, path, contents, 0644)) - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.True(t, created) - assert.True(t, state.Enabled) - assert.Equal(t, SchemaVersion, state.SchemaVersion) - assert.NoError(t, uuid.Validate(state.DeviceID)) - assert.NoError(t, uuid.Validate(state.SessionID)) - saved, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, state, saved) - }) - } - }) - - t.Run("rotates stale session after inactivity threshold", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - initial := State{ - Enabled: true, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now.Add(-(sessionRotationThreshold + time.Minute)), - DistinctID: "user-123", - SchemaVersion: SchemaVersion, - } - require.NoError(t, SaveState(initial, fsys)) - - state, created, err := LoadOrCreateState(fsys, now) - - require.NoError(t, err) - assert.False(t, created) - assert.Equal(t, initial.DeviceID, state.DeviceID) - assert.NotEqual(t, initial.SessionID, state.SessionID) - assert.Equal(t, "user-123", state.DistinctID) - assert.Equal(t, now, state.SessionLastActive) - - saved, err := LoadState(fsys) - require.NoError(t, err) - assert.Equal(t, state, saved) - }) -} - -func TestTelemetryDisabled(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - - t.Run("honors DO_NOT_TRACK", func(t *testing.T) { - t.Setenv("DO_NOT_TRACK", "1") - fsys := afero.NewMemMapFs() - - disabled, err := Disabled(fsys, now) - - require.NoError(t, err) - assert.True(t, disabled) - }) - - t.Run("honors SUPABASE_TELEMETRY_DISABLED", func(t *testing.T) { - t.Setenv("SUPABASE_TELEMETRY_DISABLED", "1") - fsys := afero.NewMemMapFs() - - disabled, err := Disabled(fsys, now) - - require.NoError(t, err) - assert.True(t, disabled) - }) - - t.Run("honors disabled state file", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - require.NoError(t, SaveState(State{ - Enabled: false, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - }, fsys)) - - disabled, err := Disabled(fsys, now) - - require.NoError(t, err) - assert.True(t, disabled) - }) - - t.Run("creates enabled state when missing", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - - disabled, err := Disabled(fsys, now) - - require.NoError(t, err) - assert.False(t, disabled) - }) -} - -func TestSetEnabledAndStatus(t *testing.T) { - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) - - t.Run("disable preserves identity fields", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - initial, _, err := LoadOrCreateState(fsys, now) - require.NoError(t, err) - initial.DistinctID = "user-123" - require.NoError(t, SaveState(initial, fsys)) - - state, err := SetEnabled(fsys, false, now.Add(time.Minute)) - - require.NoError(t, err) - assert.False(t, state.Enabled) - assert.Equal(t, initial.DeviceID, state.DeviceID) - assert.Equal(t, initial.SessionID, state.SessionID) - assert.Equal(t, "user-123", state.DistinctID) - }) - - t.Run("enable flips disabled state back on", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - require.NoError(t, SaveState(State{ - Enabled: false, - DeviceID: uuid.NewString(), - SessionID: uuid.NewString(), - SessionLastActive: now, - SchemaVersion: SchemaVersion, - }, fsys)) - - state, err := SetEnabled(fsys, true, now.Add(time.Minute)) - - require.NoError(t, err) - assert.True(t, state.Enabled) - }) - - t.Run("status creates default state when missing", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") - fsys := afero.NewMemMapFs() - - state, created, err := Status(fsys, now) - - require.NoError(t, err) - assert.True(t, created) - assert.True(t, state.Enabled) - assert.NoError(t, uuid.Validate(state.DeviceID)) - }) -} diff --git a/apps/cli-go/internal/testing/apitest/docker.go b/apps/cli-go/internal/testing/apitest/docker.go deleted file mode 100644 index b3c80f4b40..0000000000 --- a/apps/cli-go/internal/testing/apitest/docker.go +++ /dev/null @@ -1,156 +0,0 @@ -package apitest - -import ( - "bytes" - "fmt" - "io" - "net/http" - "strings" - - "github.com/docker/docker/api" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/api/types/versions" - "github.com/docker/docker/api/types/volume" - "github.com/docker/docker/client" - "github.com/docker/docker/pkg/stdcopy" - "github.com/go-errors/errors" - "github.com/h2non/gock" -) - -const mockHost = "http://127.0.0.1" - -func MockDocker(docker *client.Client) error { - // Skip setup if docker is already mocked - if docker.DaemonHost() == mockHost { - return nil - } - if err := client.WithVersion(api.DefaultVersion)(docker); err != nil { - return err - } - // Safe to ignore errors as transport will be replaced by gock - _ = client.WithHost(mockHost)(docker) - return client.WithHTTPClient(http.DefaultClient)(docker) -} - -// Ref: internal/utils/docker.go::DockerStart -func MockDockerStart(docker *client.Client, imageID, containerID string) { - gock.New(docker.DaemonHost()). - Get("/v" + docker.ClientVersion() + "/images/" + imageID + "/json"). - Reply(http.StatusOK). - JSON(image.InspectResponse{}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/networks/create"). - Reply(http.StatusCreated). - JSON(network.CreateResponse{}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/volumes/create"). - Persist(). - Reply(http.StatusCreated). - JSON(volume.Volume{}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/containers/create"). - Reply(http.StatusOK). - JSON(container.CreateResponse{ID: containerID}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/containers/" + containerID + "/start"). - Reply(http.StatusAccepted) -} - -// Ref: internal/utils/docker.go::DockerRemoveAll -func MockDockerStop(docker *client.Client) { - gock.New(docker.DaemonHost()). - Get("/v" + docker.ClientVersion() + "/containers/json"). - Reply(http.StatusOK). - JSON([]container.Summary{}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/containers/prune"). - Reply(http.StatusOK). - JSON(container.PruneReport{}) - if !versions.GreaterThanOrEqualTo(docker.ClientVersion(), "1.42") { - gock.New(docker.DaemonHost()). - Post("/v"+docker.ClientVersion()+"/volumes/prune"). - MatchParam("filters", `"all":{"true":true}`). - ReplyError(errors.New(`failed to parse filters for all=true&label=com.supabase.cli.project%3Dtest: "all" is an invalid volume filter`)) - } - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/volumes/prune"). - Reply(http.StatusOK). - JSON(volume.PruneReport{}) - gock.New(docker.DaemonHost()). - Post("/v" + docker.ClientVersion() + "/networks/prune"). - Reply(http.StatusOK). - JSON(network.PruneReport{}) -} - -// Ref: internal/utils/docker.go::DockerRunOnce -func setupDockerLogs(docker *client.Client, containerID, stdout string, exitCode int) error { - err := MockDockerLogsStream(docker, containerID, exitCode, strings.NewReader(stdout)) - gock.New(docker.DaemonHost()). - Delete("/v" + docker.ClientVersion() + "/containers/" + containerID). - Reply(http.StatusOK) - return err -} - -func MockDockerLogsStream(docker *client.Client, containerID string, exitCode int, r io.Reader) error { - var body bytes.Buffer - writer := stdcopy.NewStdWriter(&body, stdcopy.Stdout) - _, err := io.Copy(writer, r) - gock.New(docker.DaemonHost()). - Get("/v"+docker.ClientVersion()+"/containers/"+containerID+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Body(&body) - gock.New(docker.DaemonHost()). - Get("/v" + docker.ClientVersion() + "/containers/" + containerID + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - ExitCode: exitCode, - }}}) - return err -} - -func MockDockerLogs(docker *client.Client, containerID, stdout string) error { - return setupDockerLogs(docker, containerID, stdout, 0) -} - -func MockDockerLogsExitCode(docker *client.Client, containerID string, exitCode int) error { - return setupDockerLogs(docker, containerID, "", exitCode) -} - -// MockDockerErrorLogs streams stderr output alongside a non-zero exit code, -// mirroring a container whose command failed (e.g. pg_dump unable to reach the -// database). -func MockDockerErrorLogs(docker *client.Client, containerID string, exitCode int, stderr string) error { - var body bytes.Buffer - writer := stdcopy.NewStdWriter(&body, stdcopy.Stderr) - if _, err := io.Copy(writer, strings.NewReader(stderr)); err != nil { - return err - } - gock.New(docker.DaemonHost()). - Get("/v"+docker.ClientVersion()+"/containers/"+containerID+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Body(&body) - gock.New(docker.DaemonHost()). - Get("/v" + docker.ClientVersion() + "/containers/" + containerID + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - ExitCode: exitCode, - }}}) - gock.New(docker.DaemonHost()). - Delete("/v" + docker.ClientVersion() + "/containers/" + containerID). - Reply(http.StatusOK) - return nil -} - -func ListUnmatchedRequests() []string { - result := make([]string, len(gock.GetUnmatchedRequests())) - for i, r := range gock.GetUnmatchedRequests() { - result[i] = fmt.Sprintln(r.Method, r.URL.Path) - } - return result -} diff --git a/apps/cli-go/internal/testing/apitest/platform.go b/apps/cli-go/internal/testing/apitest/platform.go deleted file mode 100644 index 6ae3d50ded..0000000000 --- a/apps/cli-go/internal/testing/apitest/platform.go +++ /dev/null @@ -1,60 +0,0 @@ -package apitest - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "os" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func RandomAccessToken(t *testing.T) []byte { - data := make([]byte, 20) - _, err := rand.Read(data) - require.NoError(t, err) - token := make([]byte, 44) - copy(token, "sbp_") - hex.Encode(token[4:], data) - return token -} - -const letters = "abcdefghijklmnopqrstuvwxyz" - -func RandomProjectRef() string { - data := make([]byte, 20) - _, err := rand.Read(data) - if err != nil { - fmt.Fprintln(os.Stderr, err) - } - for i := range data { - n := int(data[i]) % len(letters) - data[i] = letters[n] - } - return string(data) -} - -func AssertRequestsDone(t *testing.T) { - assert.False(t, gock.HasUnmatchedRequest()) - for _, r := range gock.GetUnmatchedRequests() { - fmt.Fprintln(os.Stderr, "Unmatched:", r.Method, r.URL.Path) - } - assert.True(t, gock.IsDone()) - for _, p := range gock.Pending() { - fmt.Fprintln(os.Stderr, "Pending:", p.Request().Method, p.Request().URLStruct.Path) - } -} - -func MockPlatformAPI(t *testing.T) func() { - // Setup valid access token - token := RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - teardown := func() { - AssertRequestsDone(t) - gock.OffAll() - } - return teardown -} diff --git a/apps/cli-go/internal/testing/fstest/create.go b/apps/cli-go/internal/testing/fstest/create.go deleted file mode 100644 index a8c0734b8d..0000000000 --- a/apps/cli-go/internal/testing/fstest/create.go +++ /dev/null @@ -1,20 +0,0 @@ -package fstest - -import ( - "io/fs" - "strings" - - "github.com/spf13/afero" -) - -type CreateErrorFs struct { - afero.MemMapFs - DenyPath string -} - -func (m *CreateErrorFs) Create(name string) (afero.File, error) { - if strings.HasPrefix(name, m.DenyPath) { - return nil, fs.ErrPermission - } - return m.MemMapFs.Create(name) -} diff --git a/apps/cli-go/internal/testing/fstest/open.go b/apps/cli-go/internal/testing/fstest/open.go deleted file mode 100644 index bc756e3cb8..0000000000 --- a/apps/cli-go/internal/testing/fstest/open.go +++ /dev/null @@ -1,28 +0,0 @@ -package fstest - -import ( - "io/fs" - "os" - "strings" - - "github.com/spf13/afero" -) - -type OpenErrorFs struct { - afero.MemMapFs - DenyPath string -} - -func (m *OpenErrorFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { - if strings.HasPrefix(name, m.DenyPath) { - return nil, fs.ErrPermission - } - return m.MemMapFs.OpenFile(name, flag, perm) -} - -func (m *OpenErrorFs) Open(name string) (afero.File, error) { - if strings.HasPrefix(name, m.DenyPath) { - return nil, fs.ErrPermission - } - return m.MemMapFs.Open(name) -} diff --git a/apps/cli-go/internal/testing/fstest/stat.go b/apps/cli-go/internal/testing/fstest/stat.go deleted file mode 100644 index 3b53f882d6..0000000000 --- a/apps/cli-go/internal/testing/fstest/stat.go +++ /dev/null @@ -1,20 +0,0 @@ -package fstest - -import ( - "io/fs" - "strings" - - "github.com/spf13/afero" -) - -type StatErrorFs struct { - afero.MemMapFs - DenyPath string -} - -func (m *StatErrorFs) Stat(name string) (fs.FileInfo, error) { - if strings.HasPrefix(name, m.DenyPath) { - return nil, fs.ErrPermission - } - return m.MemMapFs.Stat(name) -} diff --git a/apps/cli-go/internal/testing/fstest/stdin.go b/apps/cli-go/internal/testing/fstest/stdin.go deleted file mode 100644 index 63e1a8a259..0000000000 --- a/apps/cli-go/internal/testing/fstest/stdin.go +++ /dev/null @@ -1,28 +0,0 @@ -package fstest - -import ( - "os" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func MockStdin(t *testing.T, input string) func() { - // Setup stdin - r, w, err := os.Pipe() - require.NoError(t, err) - if len(input) > 0 && !strings.HasSuffix(input, "\n") { - input += "\n" - } - _, err = w.WriteString(input) - require.NoError(t, err) - require.NoError(t, w.Close()) - // Replace stdin - oldStdin := os.Stdin - teardown := func() { - os.Stdin = oldStdin - } - os.Stdin = r - return teardown -} diff --git a/apps/cli-go/internal/testing/fstest/stdout.go b/apps/cli-go/internal/testing/fstest/stdout.go deleted file mode 100644 index 06c7bb3dc8..0000000000 --- a/apps/cli-go/internal/testing/fstest/stdout.go +++ /dev/null @@ -1,26 +0,0 @@ -package fstest - -import ( - "io" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func MockStdout(t *testing.T, output string) func() { - r, w, err := os.Pipe() - require.NoError(t, err) - // Replace stdout - oldStdout := os.Stdout - teardown := func() { - os.Stdout = oldStdout - assert.NoError(t, w.Close()) - data, err := io.ReadAll(r) - assert.NoError(t, err) - assert.Equal(t, output, string(data)) - } - os.Stdout = w - return teardown -} diff --git a/apps/cli-go/internal/testing/helper/history.go b/apps/cli-go/internal/testing/helper/history.go deleted file mode 100644 index 95c846b7ad..0000000000 --- a/apps/cli-go/internal/testing/helper/history.go +++ /dev/null @@ -1,28 +0,0 @@ -package helper - -import ( - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -func MockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(migration.SET_LOCK_TIMEOUT). - Query(migration.CREATE_VERSION_SCHEMA). - Reply("CREATE SCHEMA"). - Query(migration.CREATE_VERSION_TABLE). - Reply("CREATE TABLE"). - Query(migration.ADD_STATEMENTS_COLUMN). - Reply("ALTER TABLE"). - Query(migration.ADD_NAME_COLUMN). - Reply("ALTER TABLE") - return conn -} - -func MockSeedHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(migration.SET_LOCK_TIMEOUT). - Query(migration.CREATE_VERSION_SCHEMA). - Reply("CREATE SCHEMA"). - Query(migration.CREATE_SEED_TABLE). - Reply("CREATE TABLE") - return conn -} diff --git a/apps/cli-go/internal/testing/helper/privileges.go b/apps/cli-go/internal/testing/helper/privileges.go deleted file mode 100644 index 4dcca23f58..0000000000 --- a/apps/cli-go/internal/testing/helper/privileges.go +++ /dev/null @@ -1,19 +0,0 @@ -package helper - -import "github.com/supabase/cli/pkg/pgtest" - -// MockApiPrivilegesRevoke queues the per-statement responses for the default Data API privilege -// revoke that start.ApplyApiPrivileges runs when [api].auto_expose_new_tables is unset or false. -// -// The statements mirror start.RevokeDefaultDataApiPrivilegesSql. They are inlined here rather -// than imported from the start package to avoid an import cycle with that package's own -// internal (package start) tests. -func MockApiPrivilegesRevoke(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role"). - Reply("ALTER DEFAULT PRIVILEGES"). - Query("alter default privileges for role postgres in schema public\n revoke usage, select on sequences from anon, authenticated, service_role"). - Reply("ALTER DEFAULT PRIVILEGES"). - Query("alter default privileges for role postgres in schema public\n revoke execute on functions from anon, authenticated, service_role"). - Reply("ALTER DEFAULT PRIVILEGES") - return conn -} diff --git a/apps/cli-go/internal/utils/access_token.go b/apps/cli-go/internal/utils/access_token.go deleted file mode 100644 index 79489b564e..0000000000 --- a/apps/cli-go/internal/utils/access_token.go +++ /dev/null @@ -1,138 +0,0 @@ -package utils - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils/credentials" - "github.com/zalando/go-keyring" -) - -var ( - AccessTokenPattern = regexp.MustCompile(`^sbp_(oauth_|v0_)?[a-f0-9]{40}$`) - ErrInvalidToken = errors.New("Invalid access token format. Must be like `sbp_0102...1920`.") - ErrMissingToken = errors.Errorf("Access token not provided. Supply an access token by running %s or setting the SUPABASE_ACCESS_TOKEN environment variable.", Aqua("supabase login")) - ErrNotLoggedIn = errors.New("You were not logged in, nothing to do.") -) - -const AccessTokenKey = "access-token" - -func LoadAccessTokenFS(fsys afero.Fs) (string, error) { - accessToken, err := loadAccessToken(fsys) - if err != nil { - return "", err - } - if !AccessTokenPattern.MatchString(accessToken) { - return "", errors.New(ErrInvalidToken) - } - return accessToken, nil -} - -func loadAccessToken(fsys afero.Fs) (string, error) { - logger := GetDebugLogger() - // Env takes precedence - if accessToken := os.Getenv("SUPABASE_ACCESS_TOKEN"); accessToken != "" { - fmt.Fprintln(logger, "Using access token from env var...") - return accessToken, nil - } - // Load from current profile - if accessToken, err := credentials.StoreProvider.Get(CurrentProfile.Name); err == nil { - fmt.Fprintln(logger, "Using access token for profile:", CurrentProfile.Name) - return accessToken, nil - } - // Load from legacy key for backwards compatibility - if accessToken, err := credentials.StoreProvider.Get(AccessTokenKey); err == nil { - fmt.Fprintln(logger, "Using access token from credentials store...") - return accessToken, nil - } - // Fallback to token file - return fallbackLoadToken(fsys) -} - -func fallbackLoadToken(fsys afero.Fs) (string, error) { - path, err := getAccessTokenPath() - if err != nil { - return "", err - } - accessToken, err := afero.ReadFile(fsys, path) - if errors.Is(err, os.ErrNotExist) { - return "", errors.New(ErrMissingToken) - } else if err != nil { - return "", errors.Errorf("failed to read access token file: %w", err) - } - fmt.Fprintln(GetDebugLogger(), "Using access token from file:", path) - return string(accessToken), nil -} - -func SaveAccessToken(accessToken string, fsys afero.Fs) error { - // Validate access token - if !AccessTokenPattern.MatchString(accessToken) { - return errors.New(ErrInvalidToken) - } - // Save to current profile - if err := credentials.StoreProvider.Set(CurrentProfile.Name, accessToken); err != nil { - fmt.Fprintln(GetDebugLogger(), err) - } else { - return nil - } - // Fallback to token file - return fallbackSaveToken(accessToken, fsys) -} - -func fallbackSaveToken(accessToken string, fsys afero.Fs) error { - path, err := getAccessTokenPath() - if err != nil { - return err - } - if err := MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return err - } - if err := afero.WriteFile(fsys, path, []byte(accessToken), 0600); err != nil { - return errors.Errorf("failed to save access token file: %w", err) - } - return nil -} - -func DeleteAccessToken(fsys afero.Fs) error { - // Always delete the fallback token file to handle legacy CLI - if err := fallbackDeleteToken(fsys); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - // Always delete from legacy keyring - if err := credentials.StoreProvider.Delete(AccessTokenKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Fprintln(GetDebugLogger(), err) - } - // Delete profile from native credentials store - if err := credentials.StoreProvider.Delete(CurrentProfile.Name); err != nil { - if errors.Is(err, credentials.ErrNotSupported) || - errors.Is(err, keyring.ErrUnsupportedPlatform) || - errors.Is(err, keyring.ErrNotFound) { - return errors.New(ErrNotLoggedIn) - } - return errors.Errorf("failed to delete access token from keyring: %w", err) - } - return nil -} - -func fallbackDeleteToken(fsys afero.Fs) error { - path, err := getAccessTokenPath() - if err != nil { - return err - } - if err := fsys.Remove(path); err != nil { - return errors.Errorf("failed to remove access token file: %w", err) - } - return nil -} - -func getAccessTokenPath() (string, error) { - home, err := SupabaseHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, AccessTokenKey), nil -} diff --git a/apps/cli-go/internal/utils/access_token_test.go b/apps/cli-go/internal/utils/access_token_test.go deleted file mode 100644 index 602dec1f6a..0000000000 --- a/apps/cli-go/internal/utils/access_token_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package utils - -import ( - "os" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/utils/credentials" - "github.com/zalando/go-keyring" -) - -func TestLoadToken(t *testing.T) { - keyring.MockInit() - token := string(apitest.RandomAccessToken(t)) - - t.Run("loads token from env var", func(t *testing.T) { - t.Setenv("SUPABASE_ACCESS_TOKEN", token) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - loaded, err := LoadAccessTokenFS(fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, token, loaded) - }) - - t.Run("loads v0 token from env var", func(t *testing.T) { - v0Token := "sbp_v0_" + token[len("sbp_"):] - t.Setenv("SUPABASE_ACCESS_TOKEN", v0Token) - fsys := afero.NewMemMapFs() - loaded, err := LoadAccessTokenFS(fsys) - assert.NoError(t, err) - assert.Equal(t, v0Token, loaded) - }) - - t.Run("throws error on unknown version prefix", func(t *testing.T) { - t.Setenv("SUPABASE_ACCESS_TOKEN", "sbp_v1_"+token[len("sbp_"):]) - fsys := afero.NewMemMapFs() - loaded, err := LoadAccessTokenFS(fsys) - assert.ErrorIs(t, err, ErrInvalidToken) - assert.Empty(t, loaded) - }) - - t.Run("throws error on invalid token", func(t *testing.T) { - t.Setenv("SUPABASE_ACCESS_TOKEN", "invalid") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - loaded, err := LoadAccessTokenFS(fsys) - // Check error - assert.ErrorIs(t, err, ErrInvalidToken) - assert.Empty(t, loaded) - }) - - t.Run("throws error on missing token", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - loaded, err := LoadAccessTokenFS(fsys) - // Check error - assert.ErrorIs(t, err, ErrMissingToken) - assert.Empty(t, loaded) - }) -} - -func TestLoadTokenFallback(t *testing.T) { - t.Run("fallback loads from file", func(t *testing.T) { - path, err := getAccessTokenPath() - assert.NoError(t, err) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0600)) - // Run test - token, err := fallbackLoadToken(fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, token) - }) - - t.Run("throws error on home dir failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Setup empty home directory - t.Setenv("HOME", "") - // Run test - token, err := fallbackLoadToken(fsys) - // Check error - assert.ErrorContains(t, err, "$HOME is not defined") - assert.Empty(t, token) - }) - - t.Run("throws error on read failure", func(t *testing.T) { - path, err := getAccessTokenPath() - assert.NoError(t, err) - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: path} - // Run test - token, err := fallbackLoadToken(fsys) - // Check error - assert.ErrorContains(t, err, "permission denied") - assert.Empty(t, token) - }) -} - -func TestSaveToken(t *testing.T) { - keyring.MockInit() - token := string(apitest.RandomAccessToken(t)) - - t.Run("saves token to keyring", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - assert.NoError(t, SaveAccessToken(token, fsys)) - // Validate saved token - saved, err := LoadAccessTokenFS(fsys) - assert.NoError(t, err) - assert.Equal(t, token, saved) - }) - - t.Run("throws error on invalid token", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := SaveAccessToken("invalid", fsys) - // Check error - assert.ErrorIs(t, err, ErrInvalidToken) - }) -} - -func TestSaveTokenFallback(t *testing.T) { - token := string(apitest.RandomAccessToken(t)) - - t.Run("fallback saves to file", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - assert.NoError(t, fallbackSaveToken(token, fsys)) - // Validate saved token - path, err := getAccessTokenPath() - assert.NoError(t, err) - contents, err := afero.ReadFile(fsys, path) - assert.NoError(t, err) - assert.Equal(t, []byte(token), contents) - }) - - t.Run("fallback saves to SUPABASE_HOME when configured", func(t *testing.T) { - t.Setenv("HOME", "/home/test") - t.Setenv("SUPABASE_HOME", "/custom/supabase") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - assert.NoError(t, fallbackSaveToken(token, fsys)) - // Validate saved token - configuredPath := filepath.Join("/custom/supabase", AccessTokenKey) - contents, err := afero.ReadFile(fsys, configuredPath) - assert.NoError(t, err) - assert.Equal(t, []byte(token), contents) - defaultPath := filepath.Join("/home/test", ".supabase", AccessTokenKey) - exists, err := afero.Exists(fsys, defaultPath) - assert.NoError(t, err) - assert.False(t, exists) - }) - - t.Run("throws error on home dir failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Setup empty home directory - t.Setenv("HOME", "") - // Run test - err := fallbackSaveToken(token, fsys) - // Check error - assert.ErrorContains(t, err, "$HOME is not defined") - }) - - t.Run("throws error on permission denied", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Run test - err := fallbackSaveToken(token, fsys) - // Check error - assert.ErrorContains(t, err, "operation not permitted") - }) - - t.Run("throws error on write failure", func(t *testing.T) { - home, err := os.UserHomeDir() - assert.NoError(t, err) - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: home} - // Run test - err = fallbackSaveToken(token, fsys) - // Check error - assert.ErrorContains(t, err, "permission denied") - }) -} - -func TestDeleteToken(t *testing.T) { - t.Run("deletes both keyring and fallback", func(t *testing.T) { - token := string(apitest.RandomAccessToken(t)) - require.NoError(t, credentials.StoreProvider.Set(AccessTokenKey, token)) - require.NoError(t, credentials.StoreProvider.Set(CurrentProfile.Name, token)) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, fallbackSaveToken(token, fsys)) - // Run test - err := DeleteAccessToken(fsys) - // Check error - assert.NoError(t, err) - _, err = credentials.StoreProvider.Get(CurrentProfile.Name) - assert.ErrorIs(t, err, keyring.ErrNotFound) - _, err = credentials.StoreProvider.Get(AccessTokenKey) - assert.ErrorIs(t, err, keyring.ErrNotFound) - path, err := getAccessTokenPath() - assert.NoError(t, err) - exists, err := afero.Exists(fsys, path) - assert.NoError(t, err) - assert.False(t, exists) - }) - - t.Run("throws error if not logged in", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := DeleteAccessToken(fsys) - // Check error - assert.ErrorIs(t, err, ErrNotLoggedIn) - }) - - t.Run("throws error on home dir failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Setup empty home directory - t.Setenv("HOME", "") - // Run test - err := DeleteAccessToken(fsys) - // Check error - assert.ErrorContains(t, err, "$HOME is not defined") - }) -} diff --git a/apps/cli-go/internal/utils/agent.go b/apps/cli-go/internal/utils/agent.go deleted file mode 100644 index f41ad85cb2..0000000000 --- a/apps/cli-go/internal/utils/agent.go +++ /dev/null @@ -1,24 +0,0 @@ -package utils - -import "github.com/supabase/cli/internal/utils/agent" - -// AgentMode is a global flag for overriding agent detection. -// Allowed values: "auto" (default), "yes", "no". -var AgentMode = EnumFlag{ - Allowed: []string{"auto", "yes", "no"}, - Value: "auto", -} - -// IsAgentMode returns true if the CLI is being used by an AI agent. -// "yes" forces agent mode on, "no" forces it off, and "auto" (default) -// auto-detects based on environment variables. -func IsAgentMode() bool { - switch AgentMode.Value { - case "yes": - return true - case "no": - return false - default: - return agent.IsAgent() - } -} diff --git a/apps/cli-go/internal/utils/agent/agent.go b/apps/cli-go/internal/utils/agent/agent.go deleted file mode 100644 index e3b0d7f88f..0000000000 --- a/apps/cli-go/internal/utils/agent/agent.go +++ /dev/null @@ -1,61 +0,0 @@ -package agent - -import ( - "os" - "strings" -) - -// IsClaudeCode reports whether the CLI is running inside Claude Code. -func IsClaudeCode() bool { - return os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE") != "" -} - -// IsAgent checks environment variables to detect if the CLI is being invoked -// by an AI coding agent. Based on the detection logic from Vercel's -// @vercel/functions/ai package. -func IsAgent() bool { - if v := strings.TrimSpace(os.Getenv("AI_AGENT")); v != "" { - return true - } - // Cursor - if os.Getenv("CURSOR_AGENT") != "" || os.Getenv("CURSOR_EXTENSION_HOST_ROLE") != "" { - return true - } - // Gemini - if os.Getenv("GEMINI_CLI") != "" { - return true - } - // Codex - if os.Getenv("CODEX_SANDBOX") != "" || os.Getenv("CODEX_CI") != "" || os.Getenv("CODEX_THREAD_ID") != "" { - return true - } - // Antigravity - if os.Getenv("ANTIGRAVITY_AGENT") != "" { - return true - } - // Augment - if os.Getenv("AUGMENT_AGENT") != "" { - return true - } - // OpenCode - if os.Getenv("OPENCODE_CLIENT") != "" { - return true - } - // Claude Code - if IsClaudeCode() { - return true - } - // Replit - if os.Getenv("REPL_ID") != "" { - return true - } - // GitHub Copilot - if os.Getenv("COPILOT_MODEL") != "" || os.Getenv("COPILOT_ALLOW_ALL") != "" || os.Getenv("COPILOT_GITHUB_TOKEN") != "" { - return true - } - // Devin - if _, err := os.Stat("/opt/.devin"); err == nil { - return true - } - return false -} diff --git a/apps/cli-go/internal/utils/agent/agent_test.go b/apps/cli-go/internal/utils/agent/agent_test.go deleted file mode 100644 index 1fc1c8be9b..0000000000 --- a/apps/cli-go/internal/utils/agent/agent_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package agent - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// clearAgentEnv unsets all known agent environment variables for a clean test. -func clearAgentEnv(t *testing.T) { - t.Helper() - for _, key := range []string{ - "AI_AGENT", - "CURSOR_AGENT", - "CURSOR_EXTENSION_HOST_ROLE", - "GEMINI_CLI", - "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", - "ANTIGRAVITY_AGENT", - "AUGMENT_AGENT", - "OPENCODE_CLIENT", - "CLAUDECODE", "CLAUDE_CODE", - "REPL_ID", - "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN", - } { - t.Setenv(key, "") - } -} - -func TestIsAgent(t *testing.T) { - t.Run("returns false with no agent env vars", func(t *testing.T) { - clearAgentEnv(t) - assert.False(t, IsAgent()) - }) - - t.Run("detects AI_AGENT", func(t *testing.T) { - clearAgentEnv(t) - t.Setenv("AI_AGENT", "custom-agent") - assert.True(t, IsAgent()) - }) - - t.Run("ignores empty AI_AGENT", func(t *testing.T) { - clearAgentEnv(t) - t.Setenv("AI_AGENT", " ") - assert.False(t, IsAgent()) - }) - - t.Run("detects Cursor via CURSOR_TRACE_ID", func(t *testing.T) { - t.Setenv("CURSOR_EXTENSION_HOST_ROLE", "agent-exec") - assert.True(t, IsAgent()) - }) - - t.Run("detects Cursor via CURSOR_AGENT", func(t *testing.T) { - clearAgentEnv(t) - t.Setenv("CURSOR_AGENT", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects Gemini via GEMINI_CLI", func(t *testing.T) { - t.Setenv("GEMINI_CLI", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects Codex via CODEX_SANDBOX", func(t *testing.T) { - t.Setenv("CODEX_SANDBOX", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects Claude Code via CLAUDECODE", func(t *testing.T) { - t.Setenv("CLAUDECODE", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects Claude Code via CLAUDE_CODE", func(t *testing.T) { - t.Setenv("CLAUDE_CODE", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects GitHub Copilot via COPILOT_MODEL", func(t *testing.T) { - t.Setenv("COPILOT_MODEL", "gpt-4") - assert.True(t, IsAgent()) - }) - - t.Run("detects Replit via REPL_ID", func(t *testing.T) { - t.Setenv("REPL_ID", "abc") - assert.True(t, IsAgent()) - }) - - t.Run("detects Augment via AUGMENT_AGENT", func(t *testing.T) { - t.Setenv("AUGMENT_AGENT", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects OpenCode via OPENCODE_CLIENT", func(t *testing.T) { - t.Setenv("OPENCODE_CLIENT", "1") - assert.True(t, IsAgent()) - }) - - t.Run("detects Antigravity via ANTIGRAVITY_AGENT", func(t *testing.T) { - t.Setenv("ANTIGRAVITY_AGENT", "1") - assert.True(t, IsAgent()) - }) -} diff --git a/apps/cli-go/internal/utils/api.go b/apps/cli-go/internal/utils/api.go deleted file mode 100644 index 06f37a8a50..0000000000 --- a/apps/cli-go/internal/utils/api.go +++ /dev/null @@ -1,165 +0,0 @@ -package utils - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "sync" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils/cloudflare" - supabase "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" -) - -const ( - DNS_GO_NATIVE = "native" - DNS_OVER_HTTPS = "https" -) - -var OnGotrueID func(string) - -var ( - clientOnce sync.Once - apiClient *supabase.ClientWithResponses - - DNSResolver = EnumFlag{ - Allowed: []string{DNS_GO_NATIVE, DNS_OVER_HTTPS}, - Value: DNS_GO_NATIVE, - } -) - -// Performs DNS lookup via HTTPS, in case firewall blocks native netgo resolver. -func FallbackLookupIP(ctx context.Context, host string) ([]string, error) { - if net.ParseIP(host) != nil { - return []string{host}, nil - } - // Ref: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json - cf := cloudflare.NewCloudflareAPI() - data, err := cf.DNSQuery(ctx, cloudflare.DNSParams{Name: host}) - if err != nil { - return nil, err - } - // Look for first valid IP - var resolved []string - for _, answer := range data.Answer { - if answer.Type == cloudflare.TypeA || answer.Type == cloudflare.TypeAAAA { - resolved = append(resolved, answer.Data) - } - } - if len(resolved) == 0 { - return nil, errors.Errorf("failed to locate valid IP for %s; resolves to %#v", host, data.Answer) - } - return resolved, nil -} - -func ResolveCNAME(ctx context.Context, host string) (string, error) { - // Ref: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json - cf := cloudflare.NewCloudflareAPI() - data, err := cf.DNSQuery(ctx, cloudflare.DNSParams{Name: host, Type: cast.Ptr(cloudflare.TypeCNAME)}) - if err != nil { - return "", err - } - // Look for first valid IP - for _, answer := range data.Answer { - if answer.Type == cloudflare.TypeCNAME { - return answer.Data, nil - } - } - serialized, err := json.MarshalIndent(data.Answer, "", " ") - if err != nil { - // we ignore the error (not great), and use the underlying struct in our error message - return "", errors.Errorf("failed to locate appropriate CNAME record for %s; resolves to %+v", host, data.Answer) - } - return "", errors.Errorf("failed to locate appropriate CNAME record for %s; resolves to %+v", host, serialized) -} - -type DialContextFunc func(context.Context, string, string) (net.Conn, error) - -// Wraps a DialContext with DNS-over-HTTPS as fallback resolver -func withFallbackDNS(dialContext DialContextFunc) DialContextFunc { - dnsOverHttps := func(ctx context.Context, network, address string) (net.Conn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, errors.Errorf("failed to split host port: %w", err) - } - ip, err := FallbackLookupIP(ctx, host) - if err != nil { - return nil, err - } - conn, err := dialContext(ctx, network, net.JoinHostPort(ip[0], port)) - if err != nil { - return nil, errors.Errorf("failed to dial fallback: %w", err) - } - return conn, nil - } - if DNSResolver.Value == DNS_OVER_HTTPS { - return dnsOverHttps - } - nativeWithFallback := func(ctx context.Context, network, address string) (net.Conn, error) { - conn, err := dialContext(ctx, network, address) - // Workaround when pure Go DNS resolver fails https://github.com/golang/go/issues/12524 - if err, ok := err.(net.Error); ok && err.Timeout() { - if conn, err := dnsOverHttps(ctx, network, address); err == nil { - return conn, nil - } - } - if err != nil { - return nil, errors.Errorf("failed to dial native: %w", err) - } - return conn, nil - } - return nativeWithFallback -} - -func GetSupabase() *supabase.ClientWithResponses { - clientOnce.Do(func() { - token, err := LoadAccessTokenFS(afero.NewOsFs()) - if err != nil { - log.Fatalln(err) - } - if t, ok := http.DefaultTransport.(*http.Transport); ok { - t.DialContext = withFallbackDNS(t.DialContext) - } - transport := &identityTransport{ - RoundTripper: http.DefaultTransport, - onGotrueID: &OnGotrueID, - } - apiClient, err = supabase.NewClientWithResponses( - GetSupabaseAPIHost(), - supabase.WithHTTPClient(&http.Client{Transport: transport}), - supabase.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("User-Agent", "SupabaseCLI/"+Version) - return nil - }), - ) - if err != nil { - log.Fatalln(err) - } - }) - return apiClient -} - -// Used by unit tests -var DefaultApiHost = CurrentProfile.APIURL - -func GetSupabaseAPIHost() string { - return CurrentProfile.APIURL -} - -func GetSupabaseDashboardURL() string { - return CurrentProfile.DashboardURL -} - -func GetSupabaseHost(projectRef string) string { - return fmt.Sprintf("%s.%s", projectRef, CurrentProfile.ProjectHost) -} - -func GetSupabaseDbHost(projectRef string) string { - return "db." + GetSupabaseHost(projectRef) -} diff --git a/apps/cli-go/internal/utils/api_test.go b/apps/cli-go/internal/utils/api_test.go deleted file mode 100644 index 09365897d9..0000000000 --- a/apps/cli-go/internal/utils/api_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package utils - -import ( - "context" - "errors" - "net" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils/cloudflare" -) - -const host = "api.supabase.io" - -func TestLookupIP(t *testing.T) { - t.Run("resolves IPv4 with CloudFlare", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - // Run test - ip, err := FallbackLookupIP(context.Background(), host) - // Validate output - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"127.0.0.1"}, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("resolves IPv6 recursively", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "api.supabase.com"). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeCNAME, Data: "supabase-api.fly.dev."}, - {Type: cloudflare.TypeAAAA, Data: "2606:2800:220:1:248:1893:25c8:1946"}, - }}) - // Run test - ip, err := FallbackLookupIP(context.Background(), "api.supabase.com") - // Validate output - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"2606:2800:220:1:248:1893:25c8:1946"}, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("returns immediately if already resolved", func(t *testing.T) { - // Run test - ip, err := FallbackLookupIP(context.Background(), "127.0.0.1") - // Validate output - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"127.0.0.1"}, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("empty on network failure", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - ReplyError(errors.New("network error")) - // Run test - ip, err := FallbackLookupIP(context.Background(), host) - // Validate output - assert.ErrorContains(t, err, "network error") - assert.Empty(t, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("empty on service unavailable", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusServiceUnavailable) - // Run test - ip, err := FallbackLookupIP(context.Background(), host) - // Validate output - assert.ErrorContains(t, err, "status 503") - assert.Empty(t, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("empty on malformed json", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON("malformed") - // Run test - ip, err := FallbackLookupIP(context.Background(), host) - // Validate output - assert.ErrorContains(t, err, "invalid character 'm' looking for beginning of value") - assert.Empty(t, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("empty on no answer", func(t *testing.T) { - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{}) - // Run test - ip, err := FallbackLookupIP(context.Background(), host) - // Validate output - assert.ErrorContains(t, err, "failed to locate valid IP for api.supabase.io; resolves to []cloudflare.DNSAnswer(nil)") - assert.Empty(t, ip) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestResolveCNAME(t *testing.T) { - t.Run("resolves CNAMEs with CloudFlare", func(t *testing.T) { - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchParam("type", "5"). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeCNAME, Data: "foobarbaz.supabase.co"}, - }}) - // Run test - cname, err := ResolveCNAME(context.Background(), host) - // Validate output - assert.Equal(t, "foobarbaz.supabase.co", cname) - assert.Nil(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("missing CNAMEs return an error", func(t *testing.T) { - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchParam("type", "5"). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{}}) - // Run test - cname, err := ResolveCNAME(context.Background(), host) - // Validate output - assert.Empty(t, cname) - assert.ErrorContains(t, err, "failed to locate appropriate CNAME record for api.supabase.io") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("missing CNAMEs return an error", func(t *testing.T) { - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchParam("type", "5"). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - // Run test - cname, err := ResolveCNAME(context.Background(), host) - // Validate output - assert.Empty(t, cname) - assert.ErrorContains(t, err, "failed to locate appropriate CNAME record for api.supabase.io") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -type MockDialer struct { - mock.Mock -} - -func (m *MockDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - args := m.Called(ctx, network, address) - if conn, ok := args.Get(0).(net.Conn); ok { - return conn, args.Error(1) - } - return nil, args.Error(1) -} - -func TestFallbackDNS(t *testing.T) { - errNetwork := errors.New("network error") - errDNS := &net.DNSError{ - IsTimeout: true, - } - - t.Run("overrides DialContext with DoH", func(t *testing.T) { - DNSResolver.Value = DNS_OVER_HTTPS - // Setup mock dialer - dialer := MockDialer{} - dialer.On("DialContext", mock.Anything, mock.Anything, "127.0.0.1:80"). - Return(nil, errNetwork) - wrapped := withFallbackDNS(dialer.DialContext) - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - // Run test - conn, err := wrapped(context.Background(), "udp", host+":80") - // Check error - assert.ErrorIs(t, err, errNetwork) - assert.Nil(t, conn) - dialer.AssertExpectations(t) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("native with DoH fallback", func(t *testing.T) { - DNSResolver.Value = DNS_GO_NATIVE - // Setup mock dialer - dialer := MockDialer{} - dialer.On("DialContext", mock.Anything, mock.Anything, host+":80"). - Return(nil, errDNS) - dialer.On("DialContext", mock.Anything, mock.Anything, "127.0.0.1:80"). - Return(nil, nil) - wrapped := withFallbackDNS(dialer.DialContext) - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - // Run test - conn, err := wrapped(context.Background(), "udp", host+":80") - // Check error - assert.NoError(t, err) - assert.Nil(t, conn) - dialer.AssertExpectations(t) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on malformed address", func(t *testing.T) { - DNSResolver.Value = DNS_OVER_HTTPS - // Setup mock dialer - dialer := MockDialer{} - wrapped := withFallbackDNS(dialer.DialContext) - // Run test - conn, err := wrapped(context.Background(), "udp", "bad?url") - // Check error - assert.ErrorContains(t, err, "missing port in address") - assert.Nil(t, conn) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on fallback failure", func(t *testing.T) { - DNSResolver.Value = DNS_GO_NATIVE - // Setup mock dialer - dialer := MockDialer{} - dialer.On("DialContext", mock.Anything, mock.Anything, host+":80"). - Return(nil, errDNS) - wrapped := withFallbackDNS(dialer.DialContext) - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", host). - MatchHeader("accept", "application/dns-json"). - ReplyError(errNetwork) - // Run test - conn, err := wrapped(context.Background(), "udp", host+":80") - // Check error - assert.ErrorIs(t, err, errDNS) - assert.Nil(t, conn) - dialer.AssertExpectations(t) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/utils/cloudflare/api.go b/apps/cli-go/internal/utils/cloudflare/api.go deleted file mode 100644 index 7c7cf10a28..0000000000 --- a/apps/cli-go/internal/utils/cloudflare/api.go +++ /dev/null @@ -1,29 +0,0 @@ -package cloudflare - -import ( - "net/http" - "time" - - "github.com/supabase/cli/pkg/fetcher" -) - -type CloudflareAPI struct { - *fetcher.Fetcher -} - -func NewCloudflareAPI() CloudflareAPI { - server := "https://1.1.1.1" - client := &http.Client{ - Timeout: 10 * time.Second, - } - header := func(req *http.Request) { - req.Header.Add("accept", "application/dns-json") - } - api := CloudflareAPI{Fetcher: fetcher.NewFetcher( - server, - fetcher.WithHTTPClient(client), - fetcher.WithRequestEditor(header), - fetcher.WithExpectedStatus(http.StatusOK), - )} - return api -} diff --git a/apps/cli-go/internal/utils/cloudflare/dns.go b/apps/cli-go/internal/utils/cloudflare/dns.go deleted file mode 100644 index f8a06367cf..0000000000 --- a/apps/cli-go/internal/utils/cloudflare/dns.go +++ /dev/null @@ -1,154 +0,0 @@ -package cloudflare - -import ( - "context" - "net/http" - - "github.com/go-errors/errors" - "github.com/google/go-querystring/query" - "github.com/supabase/cli/pkg/fetcher" -) - -type DNSType uint16 - -// Spec: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4 -// Impl: https://github.com/miekg/dns/blob/master/types.go#L23 -const ( - TypeNone DNSType = 0 - TypeA DNSType = 1 // IPv4 - TypeNS DNSType = 2 - TypeMD DNSType = 3 - TypeMF DNSType = 4 - TypeCNAME DNSType = 5 - TypeSOA DNSType = 6 - TypeMB DNSType = 7 - TypeMG DNSType = 8 - TypeMR DNSType = 9 - TypeNULL DNSType = 10 - TypePTR DNSType = 12 - TypeHINFO DNSType = 13 - TypeMINFO DNSType = 14 - TypeMX DNSType = 15 - TypeTXT DNSType = 16 - TypeRP DNSType = 17 - TypeAFSDB DNSType = 18 - TypeX25 DNSType = 19 - TypeISDN DNSType = 20 - TypeRT DNSType = 21 - TypeNSAPPTR DNSType = 23 - TypeSIG DNSType = 24 - TypeKEY DNSType = 25 - TypePX DNSType = 26 - TypeGPOS DNSType = 27 - TypeAAAA DNSType = 28 // IPv6 - TypeLOC DNSType = 29 - TypeNXT DNSType = 30 - TypeEID DNSType = 31 - TypeNIMLOC DNSType = 32 - TypeSRV DNSType = 33 - TypeATMA DNSType = 34 - TypeNAPTR DNSType = 35 - TypeKX DNSType = 36 - TypeCERT DNSType = 37 - TypeDNAME DNSType = 39 - TypeOPT DNSType = 41 // EDNS - TypeAPL DNSType = 42 - TypeDS DNSType = 43 - TypeSSHFP DNSType = 44 - TypeIPSECKEY DNSType = 45 - TypeRRSIG DNSType = 46 - TypeNSEC DNSType = 47 - TypeDNSKEY DNSType = 48 - TypeDHCID DNSType = 49 - TypeNSEC3 DNSType = 50 - TypeNSEC3PARAM DNSType = 51 - TypeTLSA DNSType = 52 - TypeSMIMEA DNSType = 53 - TypeHIP DNSType = 55 - TypeNINFO DNSType = 56 - TypeRKEY DNSType = 57 - TypeTALINK DNSType = 58 - TypeCDS DNSType = 59 - TypeCDNSKEY DNSType = 60 - TypeOPENPGPKEY DNSType = 61 - TypeCSYNC DNSType = 62 - TypeZONEMD DNSType = 63 - TypeSVCB DNSType = 64 - TypeHTTPS DNSType = 65 - TypeSPF DNSType = 99 - TypeUINFO DNSType = 100 - TypeUID DNSType = 101 - TypeGID DNSType = 102 - TypeUNSPEC DNSType = 103 - TypeNID DNSType = 104 - TypeL32 DNSType = 105 - TypeL64 DNSType = 106 - TypeLP DNSType = 107 - TypeEUI48 DNSType = 108 - TypeEUI64 DNSType = 109 - TypeURI DNSType = 256 - TypeCAA DNSType = 257 - TypeAVC DNSType = 258 - TypeAMTRELAY DNSType = 260 - - TypeTKEY DNSType = 249 - TypeTSIG DNSType = 250 - - // valid Question.Qtype only - TypeIXFR DNSType = 251 - TypeAXFR DNSType = 252 - TypeMAILB DNSType = 253 - TypeMAILA DNSType = 254 - TypeANY DNSType = 255 - - TypeTA DNSType = 32768 - TypeDLV DNSType = 32769 - TypeReserved DNSType = 65535 -) - -type DNSQuestion struct { - Name string `json:"name"` - Type DNSType `json:"type"` -} - -type DNSAnswer struct { - Name string `json:"name"` - Type DNSType `json:"type"` - Ttl uint32 `json:"TTL"` - Data string `json:"data"` -} - -// Ref: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 -type DNSResponse struct { - Status uint16 - TC bool - RD bool - RA bool - AD bool - CD bool - Question []DNSQuestion `json:",omitempty"` - Answer []DNSAnswer `json:",omitempty"` - Authority []DNSAnswer `json:",omitempty"` - Additional []DNSAnswer `json:",omitempty"` -} - -type DNSParams struct { - Name string `url:"name"` - Type *DNSType `url:"type,omitempty"` - Do *bool `url:"do,omitempty"` - Cd *bool `url:"cd,omitempty"` -} - -// Performs DNS lookup via HTTPS, in case firewall blocks native netgo resolver. -// Ref: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json -func (c *CloudflareAPI) DNSQuery(ctx context.Context, params DNSParams) (DNSResponse, error) { - values, err := query.Values(params) - if err != nil { - return DNSResponse{}, errors.Errorf("failed to encode query params: %w", err) - } - resp, err := c.Send(ctx, http.MethodGet, "/dns-query?"+values.Encode(), nil) - if err != nil { - return DNSResponse{}, err - } - return fetcher.ParseJSON[DNSResponse](resp.Body) -} diff --git a/apps/cli-go/internal/utils/cloudflare/dns_test.go b/apps/cli-go/internal/utils/cloudflare/dns_test.go deleted file mode 100644 index ee4d00c299..0000000000 --- a/apps/cli-go/internal/utils/cloudflare/dns_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package cloudflare - -import ( - "context" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/apitest" -) - -func TestDNSQuery(t *testing.T) { - t.Run("successfully queries A records", func(t *testing.T) { - api := NewCloudflareAPI() - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "example.com"). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(DNSResponse{ - Answer: []DNSAnswer{ - { - Name: "example.com", - Type: TypeA, - Ttl: 300, - Data: "93.184.216.34", - }, - }, - }) - - resp, err := api.DNSQuery(context.Background(), DNSParams{ - Name: "example.com", - }) - - assert.NoError(t, err) - assert.Len(t, resp.Answer, 1) - assert.Equal(t, "93.184.216.34", resp.Answer[0].Data) - assert.Equal(t, TypeA, resp.Answer[0].Type) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("successfully queries specific DNS type", func(t *testing.T) { - api := NewCloudflareAPI() - dnsType := TypeCNAME - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "www.example.com"). - MatchParam("type", "5"). // TypeCNAME = 5 - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(DNSResponse{ - Answer: []DNSAnswer{ - { - Name: "www.example.com", - Type: TypeCNAME, - Ttl: 3600, - Data: "example.com", - }, - }, - }) - - resp, err := api.DNSQuery(context.Background(), DNSParams{ - Name: "www.example.com", - Type: &dnsType, - }) - - assert.NoError(t, err) - assert.Len(t, resp.Answer, 1) - assert.Equal(t, "example.com", resp.Answer[0].Data) - assert.Equal(t, TypeCNAME, resp.Answer[0].Type) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles network error", func(t *testing.T) { - api := NewCloudflareAPI() - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "example.com"). - ReplyError(gock.ErrCannotMatch) - - resp, err := api.DNSQuery(context.Background(), DNSParams{ - Name: "example.com", - }) - - assert.Error(t, err) - assert.Empty(t, resp.Answer) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles service unavailable", func(t *testing.T) { - api := NewCloudflareAPI() - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "example.com"). - Reply(http.StatusServiceUnavailable) - - resp, err := api.DNSQuery(context.Background(), DNSParams{ - Name: "example.com", - }) - - assert.ErrorContains(t, err, "status 503") - assert.Empty(t, resp.Answer) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles malformed response", func(t *testing.T) { - api := NewCloudflareAPI() - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "example.com"). - Reply(http.StatusOK). - JSON("invalid json") - - resp, err := api.DNSQuery(context.Background(), DNSParams{ - Name: "example.com", - }) - - assert.ErrorContains(t, err, "failed to parse") - assert.Empty(t, resp.Answer) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/utils/colors.go b/apps/cli-go/internal/utils/colors.go deleted file mode 100644 index f4f82652c0..0000000000 --- a/apps/cli-go/internal/utils/colors.go +++ /dev/null @@ -1,28 +0,0 @@ -package utils - -import ( - "github.com/charmbracelet/lipgloss" -) - -// For commands & names. -func Aqua(str string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(str) -} - -func Yellow(str string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render(str) -} - -func Green(str string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(str) -} - -// For errors. -func Red(str string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(str) -} - -// For paths & filenames. -func Bold(str string) string { - return lipgloss.NewStyle().Bold(true).Render(str) -} diff --git a/apps/cli-go/internal/utils/config.go b/apps/cli-go/internal/utils/config.go deleted file mode 100644 index 2ec13b26aa..0000000000 --- a/apps/cli-go/internal/utils/config.go +++ /dev/null @@ -1,268 +0,0 @@ -package utils - -import ( - _ "embed" - "io/fs" - "net" - "net/url" - "os" - "strconv" - - "github.com/compose-spec/compose-go/v2/types" - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/config" -) - -var ( - NetId string - DbId string - KongId string - GotrueId string - InbucketId string - RealtimeId string - RestId string - StorageId string - ImgProxyId string - DifferId string - PgmetaId string - StudioId string - EdgeRuntimeId string - LogflareId string - VectorId string - PoolerId string - - DbAliases = []string{"db", "db.supabase.internal"} - KongAliases = []string{"kong", "api.supabase.internal"} - GotrueAliases = []string{"auth"} - InbucketAliases = []string{"inbucket"} - RealtimeAliases = []string{"realtime", Config.Realtime.TenantId} - RestAliases = []string{"rest"} - StorageAliases = []string{"storage"} - ImgProxyAliases = []string{"imgproxy"} - PgmetaAliases = []string{"pg_meta"} - StudioAliases = []string{"studio"} - EdgeRuntimeAliases = []string{"edge_runtime"} - LogflareAliases = []string{"analytics"} - VectorAliases = []string{"vector"} - PoolerAliases = []string{"pooler"} - - //go:embed templates/initial_schemas/13.sql - InitialSchemaPg13Sql string - //go:embed templates/initial_schemas/14.sql - InitialSchemaPg14Sql string -) - -func GetId(name string) string { - return "supabase_" + name + "_" + Config.ProjectId -} - -func UpdateDockerIds() { - if NetId = viper.GetString("network-id"); len(NetId) == 0 { - NetId = GetId("network") - } - DbId = GetId(DbAliases[0]) - KongId = GetId(KongAliases[0]) - GotrueId = GetId(GotrueAliases[0]) - InbucketId = GetId(InbucketAliases[0]) - RealtimeId = GetId(RealtimeAliases[0]) - RestId = GetId(RestAliases[0]) - StorageId = GetId(StorageAliases[0]) - ImgProxyId = GetId(ImgProxyAliases[0]) - DifferId = GetId("differ") - PgmetaId = GetId(PgmetaAliases[0]) - StudioId = GetId(StudioAliases[0]) - EdgeRuntimeId = GetId(EdgeRuntimeAliases[0]) - LogflareId = GetId(LogflareAliases[0]) - VectorId = GetId(VectorAliases[0]) - PoolerId = GetId(PoolerAliases[0]) -} - -func GetDockerIds() []string { - return []string{ - KongId, - GotrueId, - InbucketId, - RealtimeId, - RestId, - StorageId, - ImgProxyId, - PgmetaId, - StudioId, - EdgeRuntimeId, - LogflareId, - VectorId, - PoolerId, - } -} - -var Config = config.NewConfig(config.WithHostname(GetHostname())) - -func GetServices() types.Services { - services := types.Services{ - "db": { - Name: ShortContainerImageName(Config.Db.Image), - Image: GetRegistryImageUrl(Config.Db.Image), - PullPolicy: types.PullPolicyMissing, - }, - } - if Config.Api.Enabled { - services["gateway"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Api.KongImage), - Image: GetRegistryImageUrl(Config.Api.KongImage), - PullPolicy: types.PullPolicyMissing, - } - services["api"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Api.Image), - Image: GetRegistryImageUrl(Config.Api.Image), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Auth.Enabled { - services["auth"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Auth.Image), - Image: GetRegistryImageUrl(Config.Auth.Image), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Inbucket.Enabled { - services["mailpit"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Inbucket.Image), - Image: GetRegistryImageUrl(Config.Inbucket.Image), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Realtime.Enabled { - services["realtime"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Realtime.Image), - Image: GetRegistryImageUrl(Config.Realtime.Image), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Storage.Enabled { - services["storage"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Storage.Image), - Image: GetRegistryImageUrl(Config.Storage.Image), - PullPolicy: types.PullPolicyMissing, - } - services["imgProxy"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Storage.ImgProxyImage), - Image: GetRegistryImageUrl(Config.Storage.ImgProxyImage), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.EdgeRuntime.Enabled { - services["edgeRuntime"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.EdgeRuntime.Image), - Image: GetRegistryImageUrl(Config.EdgeRuntime.Image), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Studio.Enabled { - services["studio"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Studio.Image), - Image: GetRegistryImageUrl(Config.Studio.Image), - PullPolicy: types.PullPolicyMissing, - } - services["pgmeta"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Studio.PgmetaImage), - Image: GetRegistryImageUrl(Config.Studio.PgmetaImage), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Analytics.Enabled { - services["analytics"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Analytics.Image), - Image: GetRegistryImageUrl(Config.Analytics.Image), - PullPolicy: types.PullPolicyMissing, - } - services["vector"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Analytics.VectorImage), - Image: GetRegistryImageUrl(Config.Analytics.VectorImage), - PullPolicy: types.PullPolicyMissing, - } - } - if Config.Db.Pooler.Enabled { - services["pooler"] = types.ServiceConfig{ - Name: ShortContainerImageName(Config.Db.Pooler.Image), - Image: GetRegistryImageUrl(Config.Db.Pooler.Image), - PullPolicy: types.PullPolicyMissing, - } - } - return services -} - -// Adapts fs.FS to support absolute paths -type rootFS struct { - fsys afero.Fs -} - -func (f *rootFS) Open(name string) (fs.File, error) { - return f.fsys.Open(name) -} - -func NewRootFS(fsys afero.Fs) fs.FS { - return &rootFS{fsys: fsys} -} - -func ToRealtimeEnv(addr config.AddressFamily) string { - if addr == config.AddressIPv6 { - return "-proto_dist inet6_tcp" - } - return "-proto_dist inet_tcp" -} - -type InitParams struct { - ProjectId string - UseOrioleDB bool - UsePgDelta bool - Overwrite bool -} - -func InitConfig(params InitParams, fsys afero.Fs) error { - c := config.NewConfig() - c.ProjectId = params.ProjectId - if params.UseOrioleDB { - c.Db.OrioleDBVersion = "17.11.0.002" - } - // The supabase init command opts new projects into pg-delta. Existing configs are - // unaffected because mergeDefaultValues ejects with this flag false (default stays - // migra), and other InitConfig callers leave it disabled. - c.Experimental.PgDeltaInitEnabled = params.UsePgDelta - // Create config file - if err := MkdirIfNotExistFS(fsys, SupabaseDirPath); err != nil { - return err - } - flag := os.O_WRONLY | os.O_CREATE - if params.Overwrite { - flag |= os.O_TRUNC - } else { - flag |= os.O_EXCL - } - f, err := fsys.OpenFile(ConfigPath, flag, 0644) - if err != nil { - return errors.Errorf("failed to create config file: %w", err) - } - defer f.Close() - return c.Eject(f) -} - -func WriteConfig(fsys afero.Fs, _test bool) error { - return InitConfig(InitParams{}, fsys) -} - -func GetApiUrl(path string) string { - if len(Config.Api.ExternalUrl) > 0 { - return Config.Api.ExternalUrl + path - } - hostPort := net.JoinHostPort(Config.Hostname, - strconv.FormatUint(uint64(Config.Api.Port), 10), - ) - apiUrl := url.URL{ - Scheme: "http", - Host: hostPort, - Path: path, - } - return apiUrl.String() -} diff --git a/apps/cli-go/internal/utils/config_test.go b/apps/cli-go/internal/utils/config_test.go deleted file mode 100644 index 98cd4da9e8..0000000000 --- a/apps/cli-go/internal/utils/config_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package utils - -import ( - "os" - "testing" - - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetId(t *testing.T) { - t.Run("generates container id", func(t *testing.T) { - Config.ProjectId = "test-project" - name := "test-service" - - id := GetId(name) - - assert.Equal(t, "supabase_test-service_test-project", id) - }) -} - -func TestUpdateDockerIds(t *testing.T) { - t.Run("updates all container ids", func(t *testing.T) { - Config.ProjectId = "test-project" - viper.Set("network-id", "custom-network") - defer viper.Reset() - - UpdateDockerIds() - - assert.Equal(t, "custom-network", NetId) - assert.Equal(t, "supabase_db_test-project", DbId) - assert.Equal(t, "supabase_kong_test-project", KongId) - assert.Equal(t, "supabase_auth_test-project", GotrueId) - assert.Equal(t, "supabase_inbucket_test-project", InbucketId) - assert.Equal(t, "supabase_realtime_test-project", RealtimeId) - assert.Equal(t, "supabase_rest_test-project", RestId) - assert.Equal(t, "supabase_storage_test-project", StorageId) - assert.Equal(t, "supabase_imgproxy_test-project", ImgProxyId) - assert.Equal(t, "supabase_differ_test-project", DifferId) - assert.Equal(t, "supabase_pg_meta_test-project", PgmetaId) - assert.Equal(t, "supabase_studio_test-project", StudioId) - assert.Equal(t, "supabase_edge_runtime_test-project", EdgeRuntimeId) - assert.Equal(t, "supabase_analytics_test-project", LogflareId) - assert.Equal(t, "supabase_vector_test-project", VectorId) - assert.Equal(t, "supabase_pooler_test-project", PoolerId) - }) - - t.Run("generates network id if not set", func(t *testing.T) { - Config.ProjectId = "test-project" - viper.Reset() - - UpdateDockerIds() - - assert.Equal(t, "supabase_network_test-project", NetId) - }) -} - -func TestInitConfig(t *testing.T) { - t.Run("creates new config file", func(t *testing.T) { - fsys := afero.NewMemMapFs() - params := InitParams{ - ProjectId: "test-project", - } - - err := InitConfig(params, fsys) - - assert.NoError(t, err) - exists, err := afero.Exists(fsys, ConfigPath) - assert.NoError(t, err) - assert.True(t, exists) - }) - - t.Run("generated config enables pgdelta when requested", func(t *testing.T) { - fsys := afero.NewMemMapFs() - params := InitParams{ - ProjectId: "test-project", - UsePgDelta: true, - } - - err := InitConfig(params, fsys) - - require.NoError(t, err) - content, err := afero.ReadFile(fsys, ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = true") - }) - - t.Run("generated config leaves pgdelta disabled by default", func(t *testing.T) { - fsys := afero.NewMemMapFs() - params := InitParams{ - ProjectId: "test-project", - } - - err := InitConfig(params, fsys) - - require.NoError(t, err) - content, err := afero.ReadFile(fsys, ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = false") - }) - - t.Run("creates config with orioledb", func(t *testing.T) { - fsys := afero.NewMemMapFs() - params := InitParams{ - ProjectId: "test-project", - UseOrioleDB: true, - } - - err := InitConfig(params, fsys) - - assert.NoError(t, err) - content, err := afero.ReadFile(fsys, ConfigPath) - assert.NoError(t, err) - assert.Contains(t, string(content), "17.11.0.002") - }) - - t.Run("fails if config exists and no overwrite", func(t *testing.T) { - fsys := afero.NewMemMapFs() - err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) - require.NoError(t, err) - params := InitParams{ - ProjectId: "test-project", - } - - err = InitConfig(params, fsys) - - assert.ErrorIs(t, err, os.ErrExist) - }) - - t.Run("overwrites existing config when specified", func(t *testing.T) { - fsys := afero.NewMemMapFs() - err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) - require.NoError(t, err) - params := InitParams{ - ProjectId: "test-project", - Overwrite: true, - } - - err = InitConfig(params, fsys) - - assert.NoError(t, err) - content, err := afero.ReadFile(fsys, ConfigPath) - assert.NoError(t, err) - assert.NotEqual(t, "existing", string(content)) - }) -} - -func TestGetApiUrl(t *testing.T) { - t.Run("returns external url when configured", func(t *testing.T) { - Config.Api.ExternalUrl = "https://api.example.com" - - url := GetApiUrl("/test") - - assert.Equal(t, "https://api.example.com/test", url) - }) - - t.Run("builds url from hostname and port", func(t *testing.T) { - Config.Hostname = "localhost" - Config.Api.Port = 8000 - Config.Api.ExternalUrl = "" - - url := GetApiUrl("/test") - - assert.Equal(t, "http://localhost:8000/test", url) - }) -} - -func TestRootFS(t *testing.T) { - t.Run("opens file from root fs", func(t *testing.T) { - fsys := afero.NewMemMapFs() - content := []byte("test content") - err := afero.WriteFile(fsys, "/test.txt", content, 0644) - require.NoError(t, err) - - rootfs := NewRootFS(fsys) - f, err := rootfs.Open("/test.txt") - assert.NoError(t, err) - defer f.Close() - - buf := make([]byte, len(content)) - n, err := f.Read(buf) - assert.NoError(t, err) - assert.Equal(t, len(content), n) - assert.Equal(t, content, buf) - }) - - t.Run("returns error for non-existent file", func(t *testing.T) { - fsys := afero.NewMemMapFs() - rootfs := NewRootFS(fsys) - - _, err := rootfs.Open("/non-existent.txt") - - assert.Error(t, err) - }) -} diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go deleted file mode 100644 index ec31067d6d..0000000000 --- a/apps/cli-go/internal/utils/connect.go +++ /dev/null @@ -1,386 +0,0 @@ -package utils - -import ( - "context" - "fmt" - "io" - "net" - "net/url" - "os" - "regexp" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/debug" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/pgxv5" - "golang.org/x/net/publicsuffix" -) - -func ToPostgresURL(config pgconn.Config) string { - return toPostgresURL(config, url.UserPassword(config.User, config.Password)) -} - -func toPostgresURL(config pgconn.Config, userinfo *url.Userinfo) string { - timeoutSecond := int64(config.ConnectTimeout.Seconds()) - if timeoutSecond == 0 { - timeoutSecond = 10 - } - queryParams := fmt.Sprintf("connect_timeout=%d", timeoutSecond) - for k, v := range config.RuntimeParams { - queryParams += fmt.Sprintf("&%s=%s", k, url.QueryEscape(v)) - } - // IPv6 address must be wrapped in square brackets - host := config.Host - if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { - host = fmt.Sprintf("[%s]", host) - } - return fmt.Sprintf( - "postgresql://%s@%s:%d/%s?%s", - userinfo, - host, - config.Port, - url.PathEscape(config.Database), - queryParams, - ) -} - -var ErrPrimaryNotFound = errors.New("primary database not found") - -func GetPoolerConfigPrimary(ctx context.Context, ref string) (api.SupavisorConfigResponseOutput, error) { - var result api.SupavisorConfigResponseOutput - resp, err := GetSupabase().V1GetPoolerConfigWithResponse(ctx, ref) - if err != nil { - return result, errors.Errorf("failed to get pooler: %w", err) - } else if resp.JSON200 == nil { - return result, errors.Errorf("unexpected get pooler status %d: %s", resp.StatusCode(), string(resp.Body)) - } - for _, config := range *resp.JSON200 { - if config.DatabaseType == api.SupavisorConfigResponseOutputDatabaseTypePRIMARY { - return config, nil - } - } - return result, errors.New(ErrPrimaryNotFound) -} - -func GetPoolerConfig(projectRef string) *pgconn.Config { - logger := GetDebugLogger() - if len(Config.Db.Pooler.ConnectionString) == 0 { - fmt.Fprintln(logger, "Pooler URL is not configured") - return nil - } - poolerConfig, err := ParsePoolerURL(Config.Db.Pooler.ConnectionString) - if err != nil { - fmt.Fprintln(logger, err) - return nil - } - if poolerConfig.RuntimeParams == nil { - poolerConfig.RuntimeParams = make(map[string]string) - } - // Verify that the pooler username matches the database host being connected to - if _, ref, found := strings.Cut(poolerConfig.User, "."); !found { - for option := range strings.SplitSeq(poolerConfig.RuntimeParams["options"], ",") { - key, value, found := strings.Cut(option, "=") - if found && key == "reference" && value != projectRef { - fmt.Fprintln(logger, "Pooler options does not match project ref:", projectRef) - return nil - } - } - } else if projectRef != ref { - fmt.Fprintln(logger, "Pooler username does not match project ref:", projectRef) - return nil - } - // There is a risk of MITM attack if we simply trust the hostname specified in pooler URL. - if err := assertDomainInProfile(poolerConfig.Host); err != nil { - fmt.Fprintln(logger, err) - return nil - } - fmt.Fprintln(logger, "Using connection pooler:", Config.Db.Pooler.ConnectionString) - // Supavisor transaction mode does not support prepared statement - poolerConfig.Port = 5432 - return poolerConfig -} - -func ParsePoolerURL(connString string) (*pgconn.Config, error) { - // Remove password from pooler connection string because the placeholder text - // [YOUR-PASSWORD] messes up pgconn.ParseConfig. The password must be percent - // escaped so we cannot simply call strings.Replace with actual password. - poolerUrl := strings.ReplaceAll(connString, "[YOUR-PASSWORD]", "") - poolerConfig, err := pgconn.ParseConfig(poolerUrl) - if err != nil { - return nil, errors.Errorf("failed to parse pooler URL: %w", err) - } - return poolerConfig, nil -} - -func assertDomainInProfile(host string) error { - domain, err := publicsuffix.EffectiveTLDPlusOne(host) - if err != nil { - return errors.Errorf("failed to parse pooler TLD: %w", err) - } - if len(CurrentProfile.PoolerHost) > 0 && !strings.EqualFold(CurrentProfile.PoolerHost, domain) { - return errors.Errorf("Pooler domain does not belong to current profile: %s", domain) - } - return nil -} - -// Connnect to local Postgres with optimised settings. The caller is responsible for closing the connection returned. -func ConnectLocalPostgres(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { - if len(config.Host) == 0 { - config.Host = Config.Hostname - } - if config.Port == 0 { - config.Port = Config.Db.Port - } - if len(config.User) == 0 { - config.User = "postgres" - } - if len(config.Password) == 0 { - config.Password = Config.Db.Password - } - if len(config.Database) == 0 { - config.Database = "postgres" - } - if config.ConnectTimeout == 0 { - config.ConnectTimeout = 2 * time.Second - } - options = append(options, func(cc *pgx.ConnConfig) { - cc.TLSConfig = nil - }) - return ConnectByUrl(ctx, ToPostgresURL(config), options...) -} - -func ConnectByUrl(ctx context.Context, url string, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { - if viper.GetBool("DEBUG") { - options = append(options, debug.SetupPGX) - } - // No fallback from TLS to unsecure connection - options = append(options, func(cc *pgx.ConnConfig) { - if cc.TLSConfig == nil { - return - } - var fallbacks []*pgconn.FallbackConfig - for _, fc := range cc.Fallbacks { - if fc.TLSConfig != nil { - fallbacks = append(fallbacks, fc) - } - } - cc.Fallbacks = fallbacks - }) - conn, err := pgxv5.Connect(ctx, url, options...) - SetConnectSuggestion(err) - return conn, err -} - -const SuggestEnvVar = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD" - -// ipv6LiteralPattern matches IPv6 addresses in connection errors, e.g. Go dial -// "dial tcp [2406:da18:...]:5432" or libpq -// `connection to server at "host" (2406:da18:...), port 5432 failed`. -var ipv6LiteralPattern = regexp.MustCompile(`(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))`) - -// isIPv6ConnectivityError reports whether the connection failure stems from the -// host resolving to an IPv6 address that the current network cannot route to. -// Supabase direct database connections (db..supabase.co:5432) are -// IPv6-only unless the IPv4 add-on is enabled, so users on IPv4-only networks -// (or inside a Docker container without an IPv6 stack, e.g. `supabase db dump`) -// hit these failures. -func isIPv6ConnectivityError(msg string) bool { - lower := strings.ToLower(msg) - switch { - case strings.Contains(lower, "address family for hostname not supported"), - strings.Contains(lower, "no address associated with hostname"): - // getaddrinfo inside the pg_dump container when the host is IPv6-only and - // the container has no IPv6 stack, so AI_ADDRCONFIG filters out the AAAA - // record: "could not translate host name ... to address: Address family - // for hostname not supported" / "... No address associated with hostname". - return true - case strings.Contains(lower, "network is unreachable"): - return true - case strings.Contains(lower, "no route to host"), - strings.Contains(lower, "cannot assign requested address"): - // Require an IPv6 literal so genuine project-not-found errors (which the - // branch below maps) keep their existing suggestion. - return ipv6LiteralPattern.MatchString(msg) - } - return false -} - -// IsIPv6ConnectivityError reports whether err is a database connection failure -// caused by an IPv6 address the current network (or container) cannot reach. -func IsIPv6ConnectivityError(err error) bool { - if err == nil { - return false - } - return isIPv6ConnectivityError(err.Error()) -} - -// ipv6Suggestion is the generic, command-agnostic hint shown when a direct -// connection fails because the host is IPv6-only. It points users at the IPv4 -// transaction pooler via --db-url; SuggestIPv6Pooler upgrades it with the -// project's actual connection string when one can be fetched. -func ipv6Suggestion() string { - return fmt.Sprintf( - "Your network does not support IPv6, which is required for direct connections to the database.\n"+ - "Retry with your project's IPv4 transaction pooler connection string via %s.\n"+ - "You can copy it from the dashboard under Connect > Transaction pooler.", - Aqua("--db-url"), - ) -} - -// poolerURLPasswordPattern captures the userinfo password of a postgres -// connection string (the bytes between "user:" and the "@" host separator). -var poolerURLPasswordPattern = regexp.MustCompile(`^(postgres(?:ql)?://[^:@/]+:)[^@]*@`) - -// maskPoolerPassword replaces the password in a pooler connection string with -// the [YOUR-PASSWORD] placeholder. The Management API may return a real password -// in connection_string, and the suggestion is printed to the terminal, so the -// password must never be echoed. The placeholder keeps the hint copy-pasteable. -func maskPoolerPassword(connString string) string { - return poolerURLPasswordPattern.ReplaceAllString(connString, "${1}[YOUR-PASSWORD]@") -} - -// ipv6PoolerSuggestion is the IPv6 hint enriched with the project's transaction -// pooler connection string (password masked), ready to paste into --db-url. -func ipv6PoolerSuggestion(connString string) string { - return fmt.Sprintf( - "Your network does not support IPv6, which is required for direct connections to the database.\n"+ - "Retry through the IPv4 transaction pooler by passing it to %s", - Aqua(fmt.Sprintf(`--db-url "%s"`, maskPoolerPassword(connString))), - ) -} - -// ProjectRefFromDirectDbHost extracts the project ref from a Supabase direct -// database host (db..supabase.co|red). It returns false for any other host, -// including pooler hosts and local databases. -func ProjectRefFromDirectDbHost(host string) (string, bool) { - matches := ProjectHostPattern.FindStringSubmatch(host) - if len(matches) < 3 { - return "", false - } - return matches[2], true -} - -// WarnIPv6PoolerFallback prints a user-visible warning explaining that the direct -// database connection could not be used because the current environment does not -// support IPv6, and that the CLI is retrying through the IPv4 connection pooler. -func WarnIPv6PoolerFallback(directHost string) { - fmt.Fprintln(os.Stderr, Yellow(fmt.Sprintf( - "Warning: Direct connection to %s is unavailable because this environment does not support IPv6.\n"+ - "Retrying via the IPv4 connection pooler.", - directHost, - ))) -} - -// SuggestIPv6Pooler upgrades CmdSuggestion with the project's transaction pooler -// connection string when host is a Supabase direct database host and the pooler -// config can be fetched. Returns true when the suggestion was set. -func SuggestIPv6Pooler(ctx context.Context, host string) bool { - ref, ok := ProjectRefFromDirectDbHost(host) - if !ok { - return false - } - // GetSupabase() fatally exits when no access token is configured, so only - // reach for the API when a token is available (e.g. --db-url without login). - if _, err := LoadAccessTokenFS(afero.NewOsFs()); err != nil { - return false - } - primary, err := GetPoolerConfigPrimary(ctx, ref) - if err != nil || len(primary.ConnectionString) == 0 { - return false - } - CmdSuggestion = ipv6PoolerSuggestion(primary.ConnectionString) - return true -} - -// Sets CmdSuggestion to an actionable hint based on the given pg connection error. -func SetConnectSuggestion(err error) { - if err == nil { - return - } - msg := err.Error() - if strings.Contains(msg, "connect: connection refused") || - strings.Contains(msg, "Address not in tenant allow_list") { - CmdSuggestion = fmt.Sprintf( - "Make sure your local IP is allowed in Network Restrictions and Network Bans.\n%s/project/_/database/settings", - CurrentProfile.DashboardURL, - ) - } else if strings.Contains(msg, "SCRAM exchange: Wrong password") || strings.Contains(msg, "failed SASL auth") { - // password authentication failed for user / invalid SCRAM server-final-message received from server - CmdSuggestion = SuggestEnvVar - } else if isIPv6ConnectivityError(msg) { - CmdSuggestion = ipv6Suggestion() - } else if strings.Contains(msg, "connect: no route to host") || strings.Contains(msg, "Tenant or user not found") { - // Assumes IPv6 check has been performed before this - CmdSuggestion = "Make sure your project exists on profile: " + CurrentProfile.Name - } -} - -const ( - SUPERUSER_ROLE = "supabase_admin" - CLI_LOGIN_PREFIX = "cli_login_" - SET_SESSION_ROLE = "SET SESSION ROLE postgres" -) - -func ConnectByConfigStream(ctx context.Context, config pgconn.Config, w io.Writer, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { - if IsLocalDatabase(config) { - fmt.Fprintln(w, "Connecting to local database...") - return ConnectLocalPostgres(ctx, config, options...) - } - fmt.Fprintln(w, "Connecting to remote database...") - // Ahead of the caller's overrides so they still win, e.g. pgtest clearing TLS. - opts := append([]func(*pgx.ConnConfig){preserveTLSConfig(config)}, options...) - opts = append(opts, func(cc *pgx.ConnConfig) { - if DNSResolver.Value == DNS_OVER_HTTPS { - cc.LookupFunc = FallbackLookupIP - } - // Step down from platform provisioned login roles or privileged roles - if user := strings.Split(cc.User, ".")[0]; strings.EqualFold(user, SUPERUSER_ROLE) || - strings.HasPrefix(user, CLI_LOGIN_PREFIX) { - cc.AfterConnect = func(ctx context.Context, pgconn *pgconn.PgConn) error { - return pgconn.Exec(ctx, SET_SESSION_ROLE).Close() - } - } - }) - return ConnectByUrl(ctx, ToPostgresURL(config), opts...) -} - -// preserveTLSConfig replays the TLS state pgconn resolved from sslmode, which -// ToPostgresURL cannot serialize (it lives in TLSConfig/Fallbacks, not -// RuntimeParams) so the rebuilt URL re-resolves it from PGSSLMODE and libpq's -// "prefer" default. Nil Fallbacks, which ParseConfig never produces, marks a -// config assembled in code with no preference to replay. -func preserveTLSConfig(config pgconn.Config) func(*pgx.ConnConfig) { - return func(cc *pgx.ConnConfig) { - if config.Fallbacks == nil { - return - } - cc.TLSConfig = config.TLSConfig - // Rebuild against the URL's endpoint: callers may override the port - // (GetPoolerConfig pins 5432), and extra HA hosts are dropped anyway. - cc.Fallbacks = nil - for _, fb := range config.Fallbacks { - if fb.Host != config.Host { - break - } - cc.Fallbacks = append(cc.Fallbacks, &pgconn.FallbackConfig{ - Host: cc.Host, - Port: cc.Port, - TLSConfig: fb.TLSConfig, - }) - } - } -} - -func ConnectByConfig(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { - return ConnectByConfigStream(ctx, config, os.Stderr, options...) -} - -func IsLocalDatabase(config pgconn.Config) bool { - return config.Host == Config.Hostname && (config.Port == Config.Db.Port || config.Port == Config.Db.ShadowPort) -} diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go deleted file mode 100644 index b6ea142d91..0000000000 --- a/apps/cli-go/internal/utils/connect_test.go +++ /dev/null @@ -1,440 +0,0 @@ -package utils - -import ( - "context" - "io" - "net" - "net/http" - "os" - "testing" - - "github.com/go-errors/errors" - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils/cloudflare" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: GetSupabaseDbHost(apitest.RandomProjectRef()), - Port: 6543, - User: "admin", - Password: "password", - Database: "postgres", -} - -var ( - PG13_POOLER_URL = "postgres://postgres:[YOUR-PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?options=reference%3Dzupyfdrjfhbeevcogohz" - PG15_POOLER_URL = "postgres://postgres.zupyfdrjfhbeevcogohz:[YOUR-PASSWORD]@fly-0-sin.pooler.supabase.com:6543/postgres" -) - -func TestConnectByConfig(t *testing.T) { - t.Run("connects to remote postgres with DoH", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = "" - DNSResolver.Value = DNS_OVER_HTTPS - // Setup http mock - defer gock.OffAll() - // pgx makes 2 calls to resolve ip for each connect request - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", dbConfig.Host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", dbConfig.Host). - MatchHeader("accept", "application/dns-json"). - Reply(http.StatusOK). - JSON(&cloudflare.DNSResponse{Answer: []cloudflare.DNSAnswer{ - {Type: cloudflare.TypeA, Data: "127.0.0.1"}, - }}) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - c, err := ConnectByConfig(context.Background(), dbConfig, conn.Intercept) - require.NoError(t, err) - defer c.Close(context.Background()) - assert.NoError(t, err) - }) - - t.Run("connects with unescaped db password", func(t *testing.T) { - DNSResolver.Value = DNS_GO_NATIVE - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - config := *dbConfig.Copy() - config.Host = "localhost" - config.Password = "pass word" - c, err := ConnectByConfig(context.Background(), config, conn.Intercept) - require.NoError(t, err) - defer c.Close(context.Background()) - assert.Equal(t, config.Password, c.Config().Password) - }) - - t.Run("no retry on connecting successfully with pooler", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = PG15_POOLER_URL - DNSResolver.Value = DNS_GO_NATIVE - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - // Run test - c, err := ConnectByConfig(context.Background(), dbConfig, conn.Intercept) - // Check error - require.NoError(t, err) - defer c.Close(context.Background()) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("fallback to postgres port on dial error", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = PG15_POOLER_URL - DNSResolver.Value = DNS_OVER_HTTPS - netErr := errors.New("network error") - // Setup http mock - defer gock.OffAll() - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", dbConfig.Host). - MatchHeader("accept", "application/dns-json"). - ReplyError(&net.OpError{Op: "dial", Err: netErr}) - gock.New("https://1.1.1.1"). - Get("/dns-query"). - MatchParam("name", "fly-0-sin.pooler.supabase.com"). - MatchHeader("accept", "application/dns-json"). - ReplyError(&net.OpError{Op: "dial", Err: netErr}) - // Run test - _, err := ConnectByConfig(context.Background(), dbConfig) - // Check error - require.ErrorIs(t, err, netErr) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestConnectLocal(t *testing.T) { - t.Run("connects with debug log", func(t *testing.T) { - viper.Set("DEBUG", true) - _, err := ConnectLocalPostgres(context.Background(), pgconn.Config{Host: "0", Port: 6543}) - assert.ErrorContains(t, err, "failed to connect to postgres") - }) - - t.Run("throws error on invalid port", func(t *testing.T) { - Config.Db.Port = 0 - _, err := ConnectLocalPostgres(context.Background(), pgconn.Config{}) - assert.ErrorContains(t, err, "invalid port (outside range)") - }) -} - -func TestPoolerConfig(t *testing.T) { - oldProfile := CurrentProfile - CurrentProfile = allProfiles[0] - defer t.Cleanup(func() { CurrentProfile = oldProfile }) - - t.Run("parses options ref", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = PG13_POOLER_URL - assert.NotNil(t, GetPoolerConfig("zupyfdrjfhbeevcogohz")) - }) - - t.Run("parses username ref", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = PG15_POOLER_URL - assert.NotNil(t, GetPoolerConfig("zupyfdrjfhbeevcogohz")) - }) - - t.Run("returns nil on missing url", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = "" - assert.Nil(t, GetPoolerConfig("zupyfdrjfhbeevcogohz")) - }) - - t.Run("returns nil on malformed url", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = "malformed" - assert.Nil(t, GetPoolerConfig("zupyfdrjfhbeevcogohz")) - }) - - t.Run("returns nil on mismatched project", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = PG13_POOLER_URL - assert.Nil(t, GetPoolerConfig("nlhaskwsizylhnffaqkd")) - Config.Db.Pooler.ConnectionString = PG15_POOLER_URL - assert.Nil(t, GetPoolerConfig("nlhaskwsizylhnffaqkd")) - }) - - t.Run("returns nil on invalid host", func(t *testing.T) { - Config.Db.Pooler.ConnectionString = "postgres://postgres.zupyfdrjfhbeevcogohz:[YOUR-PASSWORD]@localhost:6543/postgres" - assert.Nil(t, GetPoolerConfig("zupyfdrjfhbeevcogohz")) - }) -} - -func TestSetConnectSuggestion(t *testing.T) { - oldProfile := CurrentProfile - CurrentProfile = allProfiles[0] - defer t.Cleanup(func() { CurrentProfile = oldProfile }) - - cases := []struct { - name string - err error - suggestion string - debug bool - }{ - { - name: "no-op on nil error", - err: nil, - suggestion: "", - }, - { - name: "no-op on unrecognised error", - err: errors.New("some unknown error"), - suggestion: "", - }, - { - name: "connection refused", - err: errors.New("connect: connection refused"), - suggestion: "Make sure your local IP is allowed in Network Restrictions and Network Bans", - }, - { - name: "address not in allow list", - err: errors.New("server error (FATAL: Address not in tenant allow_list: {1,2,3} (SQLSTATE XX000))"), - suggestion: "Make sure your local IP is allowed in Network Restrictions and Network Bans", - }, - { - name: "ssl required without debug flag", - err: errors.New("SSL connection is required"), - suggestion: "", - }, - { - // The debug proxy negotiates TLS itself, so --debug is no longer the - // reason a server rejects an unencrypted connection (#5872). - name: "ssl required with debug flag", - err: errors.New("SSL connection is required"), - debug: true, - suggestion: "", - }, - { - name: "wrong password via SCRAM", - err: errors.New("SCRAM exchange: Wrong password"), - suggestion: "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD", - }, - { - name: "failed SASL auth", - err: errors.New("failed SASL auth"), - suggestion: "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD", - }, - { - name: "ipv6 no route to host", - err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: no route to host"), - suggestion: "Your network does not support IPv6", - }, - { - name: "ipv6 network is unreachable", - err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: network is unreachable"), - suggestion: "Your network does not support IPv6", - }, - { - name: "libpq unsupported address family", - err: errors.New(`pg_dump: error: connection to server failed: could not translate host name "db.test.supabase.co" to address: Address family for hostname not supported`), - suggestion: "Your network does not support IPv6", - }, - { - name: "libpq no address associated with hostname", - err: errors.New(`pg_dump: error: could not translate host name "db.ngpopfcjxrfmzmhmmpct.supabase.co" to address: No address associated with hostname`), - suggestion: "Your network does not support IPv6", - }, - { - name: "libpq network is unreachable without literal", - err: errors.New(`connection to server at "db.test.supabase.co", port 5432 failed: Network is unreachable`), - suggestion: "Your network does not support IPv6", - }, - { - name: "libpq cannot assign requested address", - err: errors.New(`pg_dump: error: connection to server at "db.test.supabase.co" (2600:1f1c:c19:4901:963f:d22e:683a:381c), port 5432 failed: Cannot assign requested address - Is the server running on that host and accepting TCP/IP connections?`), - suggestion: "Your network does not support IPv6", - }, - { - name: "cannot assign requested address without ipv6 literal", - err: errors.New("connect: cannot assign requested address"), - suggestion: "", - }, - { - name: "no route to host without ipv6 address", - err: errors.New("connect: no route to host"), - suggestion: "Make sure your project exists on profile: " + CurrentProfile.Name, - }, - { - name: "tenant or user not found", - err: errors.New("Tenant or user not found"), - suggestion: "Make sure your project exists on profile: " + CurrentProfile.Name, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - CmdSuggestion = "" - viper.Set("DEBUG", tc.debug) - SetConnectSuggestion(tc.err) - if tc.suggestion == "" { - assert.Empty(t, CmdSuggestion) - } else { - assert.Contains(t, CmdSuggestion, tc.suggestion) - } - }) - } -} - -func TestSuggestIPv6Pooler(t *testing.T) { - ref := apitest.RandomProjectRef() - poolerURL := "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" - - t.Run("enriches suggestion with transaction pooler url", func(t *testing.T) { - CmdSuggestion = "" - t.Cleanup(func() { CmdSuggestion = "" }) - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref + "/config/database/pooler"). - Reply(http.StatusOK). - JSON([]api.SupavisorConfigResponseOutput{{ - DatabaseType: api.SupavisorConfigResponseOutputDatabaseTypePRIMARY, - ConnectionString: poolerURL, - }}) - ok := SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co") - assert.True(t, ok) - assert.Contains(t, CmdSuggestion, "--db-url") - assert.Contains(t, CmdSuggestion, poolerURL) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("masks a real password returned by the api", func(t *testing.T) { - CmdSuggestion = "" - t.Cleanup(func() { CmdSuggestion = "" }) - t.Cleanup(apitest.MockPlatformAPI(t)) - secretURL := "postgres://postgres." + ref + ":sup3r-s3cret@aws-0-us-east-1.pooler.supabase.com:6543/postgres" - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref + "/config/database/pooler"). - Reply(http.StatusOK). - JSON([]api.SupavisorConfigResponseOutput{{ - DatabaseType: api.SupavisorConfigResponseOutputDatabaseTypePRIMARY, - ConnectionString: secretURL, - }}) - ok := SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co") - assert.True(t, ok) - assert.NotContains(t, CmdSuggestion, "sup3r-s3cret") - assert.Contains(t, CmdSuggestion, "[YOUR-PASSWORD]") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("skips non-supabase host without api call", func(t *testing.T) { - CmdSuggestion = "" - assert.False(t, SuggestIPv6Pooler(context.Background(), "localhost")) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("returns false when pooler config is unavailable", func(t *testing.T) { - CmdSuggestion = "" - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref + "/config/database/pooler"). - Reply(http.StatusOK). - JSON([]api.SupavisorConfigResponseOutput{}) - assert.False(t, SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co")) - assert.Empty(t, CmdSuggestion) - }) -} - -func TestProjectRefFromDirectDbHost(t *testing.T) { - ref := apitest.RandomProjectRef() - - t.Run("extracts ref from direct host", func(t *testing.T) { - got, ok := ProjectRefFromDirectDbHost("db." + ref + ".supabase.co") - assert.True(t, ok) - assert.Equal(t, ref, got) - }) - - t.Run("rejects pooler and local hosts", func(t *testing.T) { - for _, host := range []string{ - "aws-0-us-east-1.pooler.supabase.com", - "localhost", - "127.0.0.1", - "db." + ref + ".supabase.net", - } { - _, ok := ProjectRefFromDirectDbHost(host) - assert.False(t, ok, host) - } - }) -} - -func TestWarnIPv6PoolerFallback(t *testing.T) { - oldStderr := os.Stderr - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stderr = w - t.Cleanup(func() { os.Stderr = oldStderr }) - - WarnIPv6PoolerFallback("db.test.supabase.co") - require.NoError(t, w.Close()) - out, err := io.ReadAll(r) - require.NoError(t, err) - - assert.Contains(t, string(out), "db.test.supabase.co") - assert.Contains(t, string(out), "does not support IPv6") - assert.Contains(t, string(out), "connection pooler") -} - -func TestPostgresURL(t *testing.T) { - url := ToPostgresURL(pgconn.Config{ - Host: "2406:da18:4fd:9b0d:80ec:9812:3e65:450b", - Port: 5432, - User: "postgres", - Password: "!@#$%^&*()", - RuntimeParams: map[string]string{ - "options": "test", - }, - }) - assert.Equal(t, `postgresql://postgres:%21%40%23$%25%5E&%2A%28%29@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) -} - -func TestPreserveTLSConfig(t *testing.T) { - const dsn = "postgresql://postgres:pw@example.com:5432/postgres" - - t.Run("replays the sslmode resolved from the connection string", func(t *testing.T) { - for _, sslmode := range []string{"disable", "require", "verify-full"} { - parsed, err := pgconn.ParseConfig(dsn + "?sslmode=" + sslmode) - require.NoError(t, err) - rebuilt, err := pgx.ParseConfig(ToPostgresURL(*parsed)) - require.NoError(t, err) - // Run test - preserveTLSConfig(*parsed)(rebuilt) - // Check TLS matches the DSN, not the re-parse default of "prefer" - assert.Equal(t, parsed.TLSConfig, rebuilt.TLSConfig, sslmode) - } - }) - - t.Run("mandates TLS even when PGSSLMODE would disable it", func(t *testing.T) { - t.Setenv("PGSSLMODE", "disable") - parsed, err := pgconn.ParseConfig(dsn + "?sslmode=require") - require.NoError(t, err) - require.NotNil(t, parsed.TLSConfig) - rebuilt, err := pgx.ParseConfig(ToPostgresURL(*parsed)) - require.NoError(t, err) - require.Nil(t, rebuilt.TLSConfig) - // Run test - preserveTLSConfig(*parsed)(rebuilt) - // Check the env var no longer overrides the connection string - assert.NotNil(t, rebuilt.TLSConfig) - }) - - t.Run("leaves configs assembled in code untouched", func(t *testing.T) { - rebuilt, err := pgx.ParseConfig(ToPostgresURL(dbConfig)) - require.NoError(t, err) - before := rebuilt.TLSConfig - // Run test - preserveTLSConfig(dbConfig)(rebuilt) - // Check libpq's default resolution is preserved - assert.Same(t, before, rebuilt.TLSConfig) - }) -} diff --git a/apps/cli-go/internal/utils/console.go b/apps/cli-go/internal/utils/console.go deleted file mode 100644 index 8a34b8ed0e..0000000000 --- a/apps/cli-go/internal/utils/console.go +++ /dev/null @@ -1,107 +0,0 @@ -package utils - -import ( - "bufio" - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/go-errors/errors" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/cast" - "golang.org/x/term" -) - -type Console struct { - IsTTY bool - stdin *bufio.Scanner - token chan string - mu sync.Mutex -} - -func NewConsole() *Console { - return &Console{ - IsTTY: term.IsTerminal(int(os.Stdin.Fd())), - stdin: bufio.NewScanner(os.Stdin), - token: make(chan string), - mu: sync.Mutex{}, - } -} - -// Prevent interactive terminals from hanging more than 10 minutes -const ttyTimeout = time.Minute * 10 -const nonTTYTimeout = time.Millisecond * 100 - -func (c *Console) ReadLine(ctx context.Context) string { - timeout := nonTTYTimeout - if c.IsTTY { - timeout = ttyTimeout - } - timer := time.NewTimer(timeout) - defer timer.Stop() - // Read from stdin in background - go func() { - c.mu.Lock() - defer c.mu.Unlock() - // Scan one line from input or file - if c.stdin.Scan() { - c.token <- strings.TrimSpace(c.stdin.Text()) - } - }() - var input string - select { - case input = <-c.token: - case <-ctx.Done(): - case <-timer.C: - } - return input -} - -// PromptYesNo asks yes/no questions using the label. -func (c *Console) PromptYesNo(ctx context.Context, label string, def bool) (bool, error) { - choices := "Y/n" - if !def { - choices = "y/N" - } - labelWithChoice := fmt.Sprintf("%s [%s] ", label, choices) - if viper.GetBool("YES") { - fmt.Fprintln(os.Stderr, labelWithChoice+"y") - return true, nil - } - // Any error will be handled as default value - input, err := c.PromptText(ctx, labelWithChoice) - if len(input) > 0 { - if answer := parseYesNo(input); answer != nil { - return *answer, nil - } - } - return def, err -} - -func parseYesNo(s string) *bool { - s = strings.ToLower(s) - if s == "y" || s == "yes" { - return cast.Ptr(true) - } - if s == "n" || s == "no" { - return cast.Ptr(false) - } - return nil -} - -// PromptText asks for input using the label. -func (c *Console) PromptText(ctx context.Context, label string) (string, error) { - fmt.Fprint(os.Stderr, label) - input := c.ReadLine(ctx) - // Echo to stderr for non-interactive terminals - if !c.IsTTY { - fmt.Fprintln(os.Stderr, input) - } - if err := ctx.Err(); err != nil { - return "", errors.New(err) - } - return input, nil -} diff --git a/apps/cli-go/internal/utils/console_test.go b/apps/cli-go/internal/utils/console_test.go deleted file mode 100644 index ffc9532e75..0000000000 --- a/apps/cli-go/internal/utils/console_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package utils - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/fstest" -) - -func TestPromptYesNo(t *testing.T) { - t.Run("defaults on closed stdin", func(t *testing.T) { - c := NewConsole() - c.IsTTY = false - // Run test - val, err := c.PromptYesNo(context.Background(), "test", false) - // Check error - assert.NoError(t, err) - assert.False(t, val) - }) - - t.Run("parses piped stdin", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "y")) - c := NewConsole() - // Run test - val, err := c.PromptYesNo(context.Background(), "test", false) - // Check error - assert.NoError(t, err) - assert.True(t, val) - }) -} - -func TestPromptText(t *testing.T) { - t.Run("defaults on timeout", func(t *testing.T) { - t.Cleanup(fstest.MockStdin(t, "")) - c := NewConsole() - // Run test - val, err := c.PromptText(context.Background(), "test") - // Check error - assert.NoError(t, err) - assert.Empty(t, val) - }) - - t.Run("throws error on cancel", func(t *testing.T) { - c := NewConsole() - // Setup cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - // Run test - val, err := c.PromptText(ctx, "test") - // Check error - assert.ErrorIs(t, err, context.Canceled) - assert.Empty(t, val) - }) -} diff --git a/apps/cli-go/internal/utils/container_output.go b/apps/cli-go/internal/utils/container_output.go deleted file mode 100644 index 912d071a41..0000000000 --- a/apps/cli-go/internal/utils/container_output.go +++ /dev/null @@ -1,53 +0,0 @@ -package utils - -import ( - "encoding/json" - "io" - "strings" - - "github.com/docker/docker/pkg/jsonmessage" -) - -func ProcessPullOutput(out io.ReadCloser, p Program) error { - dec := json.NewDecoder(out) - - downloads := make(map[string]struct{ current, total int64 }) - - for { - var progress jsonmessage.JSONMessage - - if err := dec.Decode(&progress); err == io.EOF { - break - } else if err != nil { - return err - } - - if strings.HasPrefix(progress.Status, "Pulling from") { - p.Send(StatusMsg(progress.Status + "...")) - } else if progress.Status == "Pulling fs layer" || progress.Status == "Waiting" { - downloads[progress.ID] = struct{ current, total int64 }{ - current: 0, - total: 0, - } - } else if progress.Status == "Downloading" { - downloads[progress.ID] = struct{ current, total int64 }{ - current: progress.Progress.Current, - total: progress.Progress.Total, - } - - var overallProgress float64 - for _, percentage := range downloads { - if percentage.total > 0 { - progress := float64(percentage.current) / float64(percentage.total) - overallProgress += progress / float64(len(downloads)) - } - } - - p.Send(ProgressMsg(&overallProgress)) - } - } - - p.Send(ProgressMsg(nil)) - - return nil -} diff --git a/apps/cli-go/internal/utils/container_output_test.go b/apps/cli-go/internal/utils/container_output_test.go deleted file mode 100644 index 44376e2d34..0000000000 --- a/apps/cli-go/internal/utils/container_output_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package utils - -import ( - "encoding/json" - "io" - "sync" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/stretchr/testify/assert" -) - -func TestProcessPullOutput(t *testing.T) { - t.Run("processes docker pull messages", func(t *testing.T) { - messages := []jsonmessage.JSONMessage{ - {Status: "Pulling from library/postgres"}, - {ID: "layer1", Status: "Pulling fs layer"}, - {ID: "layer1", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 50, Total: 100}}, - {ID: "layer2", Status: "Pulling fs layer"}, - {ID: "layer2", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 75, Total: 100}}, - } - - // Create a pipe to write messages - r, w := io.Pipe() - enc := json.NewEncoder(w) - go func() { - for _, msg := range messages { - err := enc.Encode(msg) - assert.Nil(t, err) - } - w.Close() - }() - - var status string - var progressFirst *float64 - var progress *float64 - p := NewMockProgram(func(msg tea.Msg) { - switch m := msg.(type) { - case StatusMsg: - status = string(m) - case ProgressMsg: - progress = m - if progressFirst == nil { - progressFirst = m - } - } - }) - - err := ProcessPullOutput(r, p) - - assert.NoError(t, err) - assert.Equal(t, "Pulling from library/postgres...", status) - assert.Equal(t, *progressFirst, 0.5) - assert.Nil(t, progress) - }) -} - -type MockProgram struct { - handler func(tea.Msg) - mu sync.Mutex // Add mutex for thread safety -} - -func NewMockProgram(handler func(tea.Msg)) *MockProgram { - return &MockProgram{ - handler: handler, - } -} - -func (m *MockProgram) Send(msg tea.Msg) { - if m.handler != nil { - m.mu.Lock() - m.handler(msg) - m.mu.Unlock() - } -} - -func (m *MockProgram) Start() error { - return nil -} - -func (m *MockProgram) Quit() {} diff --git a/apps/cli-go/internal/utils/credentials/input.go b/apps/cli-go/internal/utils/credentials/input.go deleted file mode 100644 index 290b807aea..0000000000 --- a/apps/cli-go/internal/utils/credentials/input.go +++ /dev/null @@ -1,30 +0,0 @@ -package credentials - -import ( - "bytes" - "fmt" - "io" - "os" - - "golang.org/x/term" -) - -func PromptMasked(stdin *os.File) string { - // Start a new line after reading input - defer fmt.Println() - // Copy if stdin is piped: https://stackoverflow.com/a/26567513 - if !term.IsTerminal(int(stdin.Fd())) { - var buf bytes.Buffer - if _, err := io.Copy(&buf, stdin); err != nil { - return "" - } - return buf.String() - } - // Read with masked tokens - bytepw, err := term.ReadPassword(int(stdin.Fd())) - if err != nil { - fmt.Fprintln(os.Stderr, "Failed to read password:", err) - return "" - } - return string(bytepw) -} diff --git a/apps/cli-go/internal/utils/credentials/input_test.go b/apps/cli-go/internal/utils/credentials/input_test.go deleted file mode 100644 index c83d6d3adc..0000000000 --- a/apps/cli-go/internal/utils/credentials/input_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package credentials - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPromptMasked(t *testing.T) { - t.Run("reads from piped stdin", func(t *testing.T) { - // Setup token - r, w, err := os.Pipe() - require.NoError(t, err) - _, err = w.WriteString("token") - require.NoError(t, err) - require.NoError(t, w.Close()) - // Run test - input := PromptMasked(r) - // Check error - assert.Equal(t, "token", input) - }) - - t.Run("empty string on closed pipe", func(t *testing.T) { - // Setup empty stdin - r, _, err := os.Pipe() - require.NoError(t, err) - require.NoError(t, r.Close()) - // Run test - input := PromptMasked(r) - // Check error - assert.Empty(t, input) - }) -} diff --git a/apps/cli-go/internal/utils/credentials/keyring_test.go b/apps/cli-go/internal/utils/credentials/keyring_test.go deleted file mode 100644 index 4f450078f2..0000000000 --- a/apps/cli-go/internal/utils/credentials/keyring_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package credentials - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" -) - -func TestDeleteAll(t *testing.T) { - service := "test-cli" - // Nothing to delete - err := keyring.DeleteAll(service) - assert.NoError(t, err) - // Setup 2 items - err = keyring.Set(service, "key1", "value") - assert.NoError(t, err) - err = keyring.Set(service, "key2", "value") - assert.NoError(t, err) - // Delete all items - err = keyring.DeleteAll(service) - assert.NoError(t, err) - // Check items are gone - _, err = keyring.Get(service, "key1") - assert.ErrorIs(t, err, keyring.ErrNotFound) - _, err = keyring.Get(service, "key2") - assert.ErrorIs(t, err, keyring.ErrNotFound) -} diff --git a/apps/cli-go/internal/utils/credentials/store.go b/apps/cli-go/internal/utils/credentials/store.go deleted file mode 100644 index d7bf9d2b2d..0000000000 --- a/apps/cli-go/internal/utils/credentials/store.go +++ /dev/null @@ -1,88 +0,0 @@ -package credentials - -import ( - "bytes" - "os" - "os/exec" - - "github.com/go-errors/errors" - "github.com/zalando/go-keyring" -) - -const namespace = "Supabase CLI" - -var ErrNotSupported = errors.New("Keyring is not supported on WSL") - -type Store interface { - Get(key string) (string, error) - Set(key, value string) error - Delete(project string) error - DeleteAll() error -} - -type KeyringStore struct{} - -var StoreProvider Store = &KeyringStore{} - -// Get retrieves the password for a project from the keyring. -func (ks *KeyringStore) Get(project string) (string, error) { - if err := assertKeyringSupported(); err != nil { - return "", err - } - val, err := keyring.Get(namespace, project) - if errors.Is(err, exec.ErrNotFound) { - return "", errors.New(ErrNotSupported) - } else if err != nil { - return "", errors.Errorf("failed to load credentials: %w", err) - } - return val, nil -} - -func (ks *KeyringStore) Set(project, password string) error { - if err := assertKeyringSupported(); err != nil { - return err - } - if err := keyring.Set(namespace, project, password); err != nil { - if errors.Is(err, exec.ErrNotFound) { - return ErrNotSupported - } - return errors.Errorf("failed to set credentials: %w", err) - } - return nil -} - -func (ks *KeyringStore) Delete(project string) error { - if err := assertKeyringSupported(); err != nil { - return err - } - if err := keyring.Delete(namespace, project); err != nil { - if errors.Is(err, exec.ErrNotFound) { - return ErrNotSupported - } - return errors.Errorf("failed to delete credentials: %w", err) - } - return nil -} - -func (ks *KeyringStore) DeleteAll() error { - if err := assertKeyringSupported(); err != nil { - return err - } - if err := keyring.DeleteAll(namespace); err != nil { - if errors.Is(err, exec.ErrNotFound) { - return ErrNotSupported - } - return errors.Errorf("failed to delete all credentials in %s: %w", namespace, err) - } - return nil -} - -func assertKeyringSupported() error { - // Suggested check: https://github.com/microsoft/WSL/issues/423 - if f, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { - if bytes.Contains(f, []byte("WSL")) || bytes.Contains(f, []byte("Microsoft")) { - return errors.New(ErrNotSupported) - } - } - return nil -} diff --git a/apps/cli-go/internal/utils/credentials/store_test.go b/apps/cli-go/internal/utils/credentials/store_test.go deleted file mode 100644 index 4bb62f6581..0000000000 --- a/apps/cli-go/internal/utils/credentials/store_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package credentials - -import ( - "testing" - - "github.com/go-errors/errors" - "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" -) - -func TestKeyringStore(t *testing.T) { - t.Run("stores and retrieves password", func(t *testing.T) { - keyring.MockInit() - project := "test-project" - password := "test-password" - - err := StoreProvider.Set(project, password) - assert.NoError(t, err) - - retrieved, err := StoreProvider.Get(project) - assert.NoError(t, err) - assert.Equal(t, password, retrieved) - }) - - t.Run("returns error for non-existent project", func(t *testing.T) { - keyring.MockInit() - project := "non-existent" - - retrieved, err := StoreProvider.Get(project) - assert.ErrorIs(t, err, keyring.ErrNotFound) - assert.Empty(t, retrieved) - }) - - t.Run("deletes specific project password", func(t *testing.T) { - keyring.MockInit() - project := "test-project" - password := "test-password" - - err := StoreProvider.Set(project, password) - assert.NoError(t, err) - err = StoreProvider.Delete(project) - assert.NoError(t, err) - - _, err = StoreProvider.Get(project) - assert.ErrorIs(t, err, keyring.ErrNotFound) - }) - - t.Run("deletes all project passwords", func(t *testing.T) { - keyring.MockInit() - projects := []string{"project1", "project2"} - - for _, project := range projects { - err := StoreProvider.Set(project, "password") - assert.NoError(t, err) - } - - err := StoreProvider.DeleteAll() - assert.NoError(t, err) - - for _, project := range projects { - _, err := StoreProvider.Get(project) - assert.ErrorIs(t, err, keyring.ErrNotFound) - } - }) -} - -func TestKeyringErrors(t *testing.T) { - t.Run("handles Get error", func(t *testing.T) { - mockErr := errors.New("mock error") - keyring.MockInitWithError(mockErr) - - _, err := StoreProvider.Get("test") - assert.ErrorIs(t, err, mockErr) - }) - - t.Run("handles Set error", func(t *testing.T) { - mockErr := errors.New("mock error") - keyring.MockInitWithError(mockErr) - - err := StoreProvider.Set("test", "pass") - assert.ErrorIs(t, err, mockErr) - }) - - t.Run("handles Delete error", func(t *testing.T) { - mockErr := errors.New("mock error") - keyring.MockInitWithError(mockErr) - - err := StoreProvider.Delete("test") - assert.ErrorIs(t, err, mockErr) - }) - - t.Run("handles DeleteAll error", func(t *testing.T) { - mockErr := errors.New("mock error") - keyring.MockInitWithError(mockErr) - - err := StoreProvider.DeleteAll() - assert.ErrorIs(t, err, mockErr) - }) -} diff --git a/apps/cli-go/internal/utils/deno.go b/apps/cli-go/internal/utils/deno.go deleted file mode 100644 index 79786c0b56..0000000000 --- a/apps/cli-go/internal/utils/deno.go +++ /dev/null @@ -1,272 +0,0 @@ -package utils - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "embed" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "strings" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/pkg/function" -) - -var ( - //go:embed denos/* - denoEmbedDir embed.FS - // Used by unit tests - DenoPathOverride string -) - -const ( - // Legacy bundle options - DockerDenoDir = "/home/deno" - DockerEszipDir = "/root/eszips" - DenoVersion = "1.30.3" -) - -func GetDenoPath() (string, error) { - if len(DenoPathOverride) > 0 { - return DenoPathOverride, nil - } - home, err := SupabaseHomeDir() - if err != nil { - return "", err - } - denoBinName := "deno" - if runtime.GOOS == "windows" { - denoBinName = "deno.exe" - } - denoPath := filepath.Join(home, denoBinName) - return denoPath, nil -} - -func InstallOrUpgradeDeno(ctx context.Context, fsys afero.Fs) error { - denoPath, err := GetDenoPath() - if err != nil { - return err - } - - if _, err := fsys.Stat(denoPath); err == nil { - // Upgrade Deno. - cmd := exec.CommandContext(ctx, denoPath, "upgrade", "--version", DenoVersion) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - return cmd.Run() - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - - // Install Deno. - if err := MkdirIfNotExistFS(fsys, filepath.Dir(denoPath)); err != nil { - return err - } - - // 1. Determine OS triple - assetFilename, err := getDenoAssetFileName() - if err != nil { - return err - } - assetRepo := "denoland/deno" - if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { - // TODO: version pin to official release once available https://github.com/denoland/deno/issues/1846 - assetRepo = "LukeChannings/deno-arm64" - } - - // 2. Download & install Deno binary. - { - assetUrl := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", assetRepo, DenoVersion, assetFilename) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetUrl, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return errors.New("Failed installing Deno binary.") - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - r, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) - // There should be only 1 file: the deno binary - if len(r.File) != 1 { - return err - } - denoContents, err := r.File[0].Open() - if err != nil { - return err - } - defer denoContents.Close() - - denoBytes, err := io.ReadAll(denoContents) - if err != nil { - return err - } - - if err := afero.WriteFile(fsys, denoPath, denoBytes, 0755); err != nil { - return err - } - } - - return nil -} - -func isScriptModified(fsys afero.Fs, destPath string, src []byte) (bool, error) { - dest, err := afero.ReadFile(fsys, destPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return true, nil - } - return false, err - } - - // compare the md5 checksum of src bytes with user's copy. - // if the checksums doesn't match, script is modified. - return sha256.Sum256(dest) != sha256.Sum256(src), nil -} - -type DenoScriptDir struct { - ExtractPath string - BuildPath string -} - -// Copy Deno scripts needed for function deploy and downloads, returning a DenoScriptDir struct or an error. -func CopyDenoScripts(ctx context.Context, fsys afero.Fs) (*DenoScriptDir, error) { - denoPath, err := GetDenoPath() - if err != nil { - return nil, err - } - - denoDirPath := filepath.Dir(denoPath) - scriptDirPath := filepath.Join(denoDirPath, "denos") - - // make the script directory if not exist - if err := MkdirIfNotExistFS(fsys, scriptDirPath); err != nil { - return nil, err - } - - // copy embed files to script directory - err = fs.WalkDir(denoEmbedDir, "denos", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // skip copying the directory - if d.IsDir() { - return nil - } - - destPath := filepath.Join(denoDirPath, path) - - contents, err := fs.ReadFile(denoEmbedDir, path) - if err != nil { - return err - } - - // check if the script should be copied - modified, err := isScriptModified(fsys, destPath, contents) - if err != nil { - return err - } - if !modified { - return nil - } - - if err := afero.WriteFile(fsys, filepath.Join(denoDirPath, path), contents, 0666); err != nil { - return err - } - - return nil - }) - if err != nil { - return nil, err - } - - sd := DenoScriptDir{ - ExtractPath: filepath.Join(scriptDirPath, "extract.ts"), - BuildPath: filepath.Join(scriptDirPath, "build.ts"), - } - - return &sd, nil -} - -func BindHostModules(cwd, relEntrypointPath, relImportMapPath string, fsys afero.Fs) ([]string, error) { - var modules []string - bindModule := func(srcPath string, r io.Reader) error { - hostPath := filepath.Join(cwd, filepath.FromSlash(srcPath)) - dockerPath := ToDockerPath(hostPath) - modules = append(modules, hostPath+":"+dockerPath+":ro") - return nil - } - importMap := function.ImportMap{} - if imPath := filepath.ToSlash(relImportMapPath); len(imPath) > 0 { - if err := importMap.LoadAsDeno(imPath, afero.NewIOFS(fsys), bindModule); err != nil { - return nil, err - } - } - // Resolving all Import Graph - addModule := func(unixPath string, w io.Writer) error { - hostPath := toHostPath(cwd, unixPath) - f, err := fsys.Open(hostPath) - if err != nil { - return errors.Errorf("failed to read file: %w", err) - } - defer f.Close() - if _, err := io.Copy(w, f); err != nil { - return errors.Errorf("failed to copy file content: %w", err) - } - dockerPath := ToDockerPath(hostPath) - modules = append(modules, hostPath+":"+dockerPath+":ro") - return nil - } - unixPath := filepath.ToSlash(relEntrypointPath) - if err := importMap.WalkImportPaths(unixPath, addModule); err != nil { - return nil, err - } - // Also mount local directories declared in scopes - for _, scope := range importMap.Scopes { - for _, unixPath := range scope { - hostPath := toHostPath(cwd, unixPath) - // Ref: https://docs.deno.com/runtime/fundamentals/modules/#overriding-https-imports - if _, err := fsys.Stat(hostPath); err != nil { - return nil, errors.Errorf("failed to resolve scope: %w", err) - } - dockerPath := ToDockerPath(hostPath) - modules = append(modules, hostPath+":"+dockerPath+":ro") - } - } - return modules, nil -} - -func toHostPath(cwd, unixPath string) string { - hostPath := filepath.FromSlash(unixPath) - if path.IsAbs(unixPath) { - return filepath.VolumeName(cwd) + hostPath - } - return filepath.Join(cwd, hostPath) -} - -func ToDockerPath(absHostPath string) string { - prefix := filepath.VolumeName(absHostPath) - dockerPath := filepath.ToSlash(absHostPath) - return strings.TrimPrefix(dockerPath, prefix) -} diff --git a/apps/cli-go/internal/utils/deno_darwin.go b/apps/cli-go/internal/utils/deno_darwin.go deleted file mode 100644 index fe0b89d10e..0000000000 --- a/apps/cli-go/internal/utils/deno_darwin.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build darwin - -package utils - -import ( - "syscall" - - "github.com/go-errors/errors" -) - -func getDenoAssetFileName() (string, error) { - // Simple runtime.GOARCH detection doesn't work if the CLI is - // running under Rosetta: - // https://github.com/supabase/cli/issues/1266. So as a workaround - // we use Apple Silicon detection: - // https://www.yellowduck.be/posts/detecting-apple-silicon-via-go. - _, err := syscall.Sysctl("sysctl.proc_translated") - if err != nil { - if err.Error() == "no such file or directory" { - // Running on Intel Mac. - return "deno-x86_64-apple-darwin.zip", nil - } else { - return "", errors.Errorf("failed to determine OS triple: %w", err) - } - } else { - // Running on Apple Silicon. - return "deno-aarch64-apple-darwin.zip", nil - } -} diff --git a/apps/cli-go/internal/utils/deno_generic.go b/apps/cli-go/internal/utils/deno_generic.go deleted file mode 100644 index 2992c7baba..0000000000 --- a/apps/cli-go/internal/utils/deno_generic.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !darwin - -package utils - -import ( - "runtime" - - "github.com/go-errors/errors" -) - -func getDenoAssetFileName() (string, error) { - if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { - return "deno-x86_64-unknown-linux-gnu.zip", nil - } else if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { - // TODO: version pin to official release once available https://github.com/denoland/deno/issues/1846 - return "deno-linux-arm64.zip", nil - } else if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" { - return "deno-x86_64-pc-windows-msvc.zip", nil - } else { - return "", errors.New("Platform " + runtime.GOOS + "/" + runtime.GOARCH + " is currently unsupported for Functions.") - } -} diff --git a/apps/cli-go/internal/utils/deno_test.go b/apps/cli-go/internal/utils/deno_test.go deleted file mode 100644 index 1c104870d7..0000000000 --- a/apps/cli-go/internal/utils/deno_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package utils - -import ( - "context" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBindModules(t *testing.T) { - t.Run("binds docker imports", func(t *testing.T) { - fsys := afero.NewMemMapFs() - entrypoint := `import "https://deno.land" -import "/tmp/index.ts" -import "../common/index.ts" -import "../../../supabase/tests/index.ts" -import "./child/index.ts"` - require.NoError(t, WriteFile("/app/supabase/functions/hello/index.ts", []byte(entrypoint), fsys)) - require.NoError(t, WriteFile("/tmp/index.ts", []byte{}, fsys)) - require.NoError(t, WriteFile("/app/supabase/functions/common/index.ts", []byte{}, fsys)) - require.NoError(t, WriteFile("/app/supabase/tests/index.ts", []byte{}, fsys)) - require.NoError(t, WriteFile("/app/supabase/functions/hello/child/index.ts", []byte{}, fsys)) - // Run test - mods, err := BindHostModules("/app", "supabase/functions/hello/index.ts", "", fsys) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, mods, []string{ - "/app/supabase/functions/hello/index.ts:/app/supabase/functions/hello/index.ts:ro", - "/tmp/index.ts:/tmp/index.ts:ro", - "/app/supabase/functions/common/index.ts:/app/supabase/functions/common/index.ts:ro", - "/app/supabase/tests/index.ts:/app/supabase/tests/index.ts:ro", - "/app/supabase/functions/hello/child/index.ts:/app/supabase/functions/hello/child/index.ts:ro", - }) - }) -} - -func TestGetDenoPath(t *testing.T) { - t.Run("returns override path when set", func(t *testing.T) { - override := "/custom/path/to/deno" - DenoPathOverride = override - defer func() { DenoPathOverride = "" }() - - path, err := GetDenoPath() - - assert.NoError(t, err) - assert.Equal(t, override, path) - }) - - t.Run("returns default path", func(t *testing.T) { - home, err := os.UserHomeDir() - require.NoError(t, err) - expected := filepath.Join(home, ".supabase", "deno") - if runtime.GOOS == "windows" { - expected += ".exe" - } - - path, err := GetDenoPath() - - assert.NoError(t, err) - assert.Equal(t, expected, path) - }) - - t.Run("returns SUPABASE_HOME path when configured", func(t *testing.T) { - t.Setenv("SUPABASE_HOME", "/custom/supabase") - expected := filepath.Join("/custom/supabase", "deno") - if runtime.GOOS == "windows" { - expected += ".exe" - } - - path, err := GetDenoPath() - - assert.NoError(t, err) - assert.Equal(t, expected, path) - }) -} - -func TestIsScriptModified(t *testing.T) { - t.Run("detects modified script", func(t *testing.T) { - fsys := afero.NewMemMapFs() - destPath := "/path/to/script.ts" - original := []byte("original content") - modified := []byte("modified content") - require.NoError(t, afero.WriteFile(fsys, destPath, modified, 0644)) - - isModified, err := isScriptModified(fsys, destPath, original) - - assert.NoError(t, err) - assert.True(t, isModified) - }) - - t.Run("detects unmodified script", func(t *testing.T) { - fsys := afero.NewMemMapFs() - destPath := "/path/to/script.ts" - content := []byte("test content") - require.NoError(t, afero.WriteFile(fsys, destPath, content, 0644)) - - isModified, err := isScriptModified(fsys, destPath, content) - - assert.NoError(t, err) - assert.False(t, isModified) - }) - - t.Run("handles non-existent script", func(t *testing.T) { - fsys := afero.NewMemMapFs() - destPath := "/path/to/script.ts" - content := []byte("test content") - - isModified, err := isScriptModified(fsys, destPath, content) - - assert.NoError(t, err) - assert.True(t, isModified) - }) -} - -func TestCopyDenoScripts(t *testing.T) { - t.Run("copies deno scripts", func(t *testing.T) { - fsys := afero.NewMemMapFs() - home, err := os.UserHomeDir() - require.NoError(t, err) - denoDir := filepath.Join(home, ".supabase") - require.NoError(t, fsys.MkdirAll(denoDir, 0755)) - - scripts, err := CopyDenoScripts(context.Background(), fsys) - - assert.NoError(t, err) - assert.NotNil(t, scripts) - extractExists, err := afero.Exists(fsys, scripts.ExtractPath) - assert.NoError(t, err) - assert.True(t, extractExists) - }) - - t.Run("skips copying unmodified scripts", func(t *testing.T) { - fsys := afero.NewMemMapFs() - home, err := os.UserHomeDir() - require.NoError(t, err) - scriptDir := filepath.Join(home, ".supabase", "denos") - require.NoError(t, fsys.MkdirAll(scriptDir, 0755)) - - scripts1, err := CopyDenoScripts(context.Background(), fsys) - require.NoError(t, err) - stat1, err := fsys.Stat(scripts1.ExtractPath) - require.NoError(t, err) - modTime1 := stat1.ModTime() - - // Second copy - scripts2, err := CopyDenoScripts(context.Background(), fsys) - require.NoError(t, err) - stat2, err := fsys.Stat(scripts2.ExtractPath) - require.NoError(t, err) - modTime2 := stat2.ModTime() - - // Verify file wasn't rewritten - assert.Equal(t, modTime1, modTime2) - }) -} diff --git a/apps/cli-go/internal/utils/denos/extract.ts b/apps/cli-go/internal/utils/denos/extract.ts deleted file mode 100644 index 1e47399677..0000000000 --- a/apps/cli-go/internal/utils/denos/extract.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as path from "https://deno.land/std@0.127.0/path/mod.ts"; -import { readAll } from "https://deno.land/std@0.162.0/streams/conversion.ts"; -import { Parser } from "https://deno.land/x/eszip@v0.30.0/mod.ts"; - -async function write(p: string, content: string) { - await Deno.mkdir(path.dirname(p), { recursive: true }); - await Deno.writeTextFile(p, content); -} - -async function loadEszip(bytes: Uint8Array) { - const parser = await Parser.createInstance(); - const specifiers = await parser.parseBytes(bytes); - await parser.load(); - return { parser, specifiers }; -} - -async function extractEszip( - destPath: string, - entrypointUrl: string, - rootPath: string, - parser: Parser, - specifiers: string[], -) { - const entrypointPath = path.fromFileUrl(entrypointUrl); - const basePath = path.dirname(entrypointPath); - // Containment is enforced against rootPath (the shared functions - // directory), not destPath (this function's own directory): a common - // layout imports a sibling like "../_shared/cors.ts", which legitimately - // resolves one level above destPath, but must never resolve outside - // rootPath entirely. - const resolvedRoot = path.resolve(rootPath); - for (const specifier of specifiers) { - // skip remote dependencies - if (!specifier.startsWith("file:")) { - continue; - } - console.error(specifier); - const module = await parser.getModuleSource(specifier); - const absPath = path.fromFileUrl(specifier); - const relPath = path.relative(basePath, absPath); - const dest = path.join(destPath, relPath); - - const resolvedDest = path.resolve(dest); - const relToRoot = path.relative(resolvedRoot, resolvedDest); - if ( - relToRoot === ".." || - relToRoot.startsWith(`..${path.SEP}`) || - path.isAbsolute(relToRoot) - ) { - throw new Error( - `Refusing to extract "${specifier}" outside of ${rootPath}`, - ); - } - - console.info(path.resolve(dest)); - await write(dest, module); - } -} - -async function extractSource( - destPath: string, - entrypointUrl: string, - rootPath: string, -) { - const buf = await readAll(Deno.stdin); - - const { parser, specifiers } = await loadEszip(buf); - await extractEszip(destPath, entrypointUrl, rootPath, parser, specifiers); -} - -extractSource(...Deno.args); diff --git a/apps/cli-go/internal/utils/docker.go b/apps/cli-go/internal/utils/docker.go deleted file mode 100644 index 29d2edd397..0000000000 --- a/apps/cli-go/internal/utils/docker.go +++ /dev/null @@ -1,686 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "os" - "regexp" - "strings" - "sync" - "time" - - "github.com/containerd/errdefs" - podman "github.com/containers/common/libnetwork/types" - "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/compose/loader" - dockerConfig "github.com/docker/cli/cli/config" - dockerFlags "github.com/docker/cli/cli/flags" - "github.com/docker/cli/cli/streams" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/mount" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/api/types/versions" - "github.com/docker/docker/api/types/volume" - "github.com/docker/docker/client" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/pkg/stdcopy" - "github.com/go-errors/errors" - "github.com/spf13/viper" - "go.opentelemetry.io/otel" -) - -var Docker = NewDocker() - -func NewDocker() *client.Client { - // TODO: refactor to initialize lazily - cli, err := command.NewDockerCli() - if err != nil { - log.Fatalln("Failed to create Docker client:", err) - } - // Silence otel errors as users don't care about docker metrics - // 2024/08/12 23:11:12 1 errors occurred detecting resource: - // * conflicting Schema URL: https://opentelemetry.io/schemas/1.21.0 - otel.SetErrorHandler(otel.ErrorHandlerFunc(func(cause error) {})) - if err := cli.Initialize(&dockerFlags.ClientOptions{}); err != nil { - log.Fatalln("Failed to initialize Docker client:", err) - } - return cli.Client().(*client.Client) -} - -const ( - DinDHost = "host.docker.internal" - CliProjectLabel = "com.supabase.cli.project" - composeProjectLabel = "com.docker.compose.project" -) - -func DockerNetworkCreateIfNotExists(ctx context.Context, mode container.NetworkMode, labels map[string]string) error { - // Non-user defined networks should already exist - if !isUserDefined(mode) { - return nil - } - _, err := Docker.NetworkCreate(ctx, mode.NetworkName(), network.CreateOptions{Labels: labels}) - // if error is network already exists, no need to propagate to user - if errdefs.IsConflict(err) || errors.Is(err, podman.ErrNetworkExists) { - return nil - } - if err != nil { - return errors.Errorf("failed to create docker network: %w", err) - } - return err -} - -func WaitAll[T any](containers []T, exec func(container T) error) []error { - var wg sync.WaitGroup - result := make([]error, len(containers)) - for i, container := range containers { - wg.Add(1) - go func(i int, container T) { - defer wg.Done() - result[i] = exec(container) - }(i, container) - } - wg.Wait() - return result -} - -// NoBackupVolume TODO: encapsulate this state in a class -var NoBackupVolume = false - -func DockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error { - fmt.Fprintln(w, "Stopping containers...") - args := CliProjectFilter(projectId) - containers, err := Docker.ContainerList(ctx, container.ListOptions{ - All: true, - Filters: args, - }) - if err != nil { - return errors.Errorf("failed to list containers: %w", err) - } - // Gracefully shutdown containers - var ids []string - for _, c := range containers { - ids = append(ids, c.ID) - } - result := WaitAll(ids, func(id string) error { - if err := Docker.ContainerStop(ctx, id, container.StopOptions{}); err != nil && !errdefs.IsNotModified(err) { - return errors.Errorf("failed to stop container: %w", err) - } - return nil - }) - if err := errors.Join(result...); err != nil { - return err - } - if report, err := Docker.ContainersPrune(ctx, args); err != nil { - return errors.Errorf("failed to prune containers: %w", err) - } else if viper.GetBool("DEBUG") { - fmt.Fprintln(os.Stderr, "Pruned containers:", report.ContainersDeleted) - } - // Remove named volumes - if NoBackupVolume { - vargs := args.Clone() - if versions.GreaterThanOrEqualTo(Docker.ClientVersion(), "1.42") { - // Since docker engine 25.0.3, all flag is required to include named volumes. - // https://github.com/docker/cli/blob/master/cli/command/volume/prune.go#L76 - vargs.Add("all", "true") - } - if report, err := Docker.VolumesPrune(ctx, vargs); err != nil { - return errors.Errorf("failed to prune volumes: %w", err) - } else if viper.GetBool("DEBUG") { - fmt.Fprintln(os.Stderr, "Pruned volumes:", report.VolumesDeleted) - } - } - // Remove networks. - if report, err := Docker.NetworksPrune(ctx, args); err != nil { - return errors.Errorf("failed to prune networks: %w", err) - } else if viper.GetBool("DEBUG") { - fmt.Fprintln(os.Stderr, "Pruned network:", report.NetworksDeleted) - } - return nil -} - -func CliProjectFilter(projectId string) filters.Args { - if len(projectId) == 0 { - return filters.NewArgs( - filters.Arg("label", CliProjectLabel), - ) - } - return filters.NewArgs( - filters.Arg("label", CliProjectLabel+"="+projectId), - ) -} - -var ( - registryAuth sync.Map -) - -// registryAuthEntry memoises a single registry's encoded auth so that -// loadRegistryAuth runs at most once per registry, even when concurrent image -// pulls request the same registry simultaneously. -type registryAuthEntry struct { - once sync.Once - value string -} - -func GetRegistryAuth() string { - return getRegistryAuth(GetRegistry()) -} - -func GetRegistryAuthForImage(imageTag string) string { - return getRegistryAuth(registryFromImage(imageTag)) -} - -func getRegistryAuth(registry string) string { - // Docker stores Hub credentials under the legacy index server, which - // resolves to the `index.docker.io` hostname; a bare `docker.io` lookup - // never matches them. Normalising here also routes images Docker treats as - // Hub references (a registry override or repository segment without a dot, - // colon, or `localhost`) to the right credentials. - if registry == "docker.io" { - registry = "index.docker.io" - } - // LoadOrStore + a per-key sync.Once guarantees loadRegistryAuth runs exactly - // once per registry, even under the concurrent image pulls `supabase start` - // issues. The result (including the empty string for a missing or unreadable - // credential entry) is then reused, so the Docker config file is read once - // and the credential warning is printed at most once per registry. - cached, _ := registryAuth.LoadOrStore(registry, ®istryAuthEntry{}) - entry := cached.(*registryAuthEntry) - entry.once.Do(func() { - entry.value = loadRegistryAuth(registry) - }) - return entry.value -} - -func loadRegistryAuth(registry string) string { - config := dockerConfig.LoadDefaultConfigFile(os.Stderr) - // Ref: https://docs.docker.com/engine/api/sdk/examples/#pull-an-image-with-authentication - auth, err := config.GetAuthConfig(registry) - if err != nil { - fmt.Fprintln(os.Stderr, "Failed to load registry credentials:", err) - return "" - } - encoded, err := json.Marshal(auth) - if err != nil { - fmt.Fprintln(os.Stderr, "Failed to serialise auth config:", err) - return "" - } - return base64.URLEncoding.EncodeToString(encoded) -} - -// Defaults to Supabase public ECR for faster image pull -const defaultRegistry = "public.ecr.aws" -const ghcrRegistry = "ghcr.io" - -func GetRegistry() string { - registry := viper.GetString("INTERNAL_IMAGE_REGISTRY") - if len(registry) == 0 { - return defaultRegistry - } - return strings.ToLower(registry) -} - -func HasRegistryOverride() bool { - return len(viper.GetString("INTERNAL_IMAGE_REGISTRY")) > 0 -} - -func GetRegistryImageUrl(imageName string) string { - registry := GetRegistry() - if registry == "docker.io" { - return imageName - } - // Configure mirror registry - parts := strings.Split(imageName, "/") - imageName = parts[len(parts)-1] - return registry + "/supabase/" + imageName -} - -func GetRegistryImageUrls(imageName string) []string { - if HasRegistryOverride() { - return []string{GetRegistryImageUrl(imageName)} - } - parts := strings.Split(imageName, "/") - lastPart := parts[len(parts)-1] - return dedupeStrings([]string{ - defaultRegistry + "/supabase/" + lastPart, - ghcrRegistry + "/supabase/" + lastPart, - dockerHubFallbackImage(imageName, lastPart), - }) -} - -func dockerHubFallbackImage(imageName, lastPart string) string { - if strings.HasPrefix(imageName, defaultRegistry+"/supabase/") || strings.HasPrefix(imageName, ghcrRegistry+"/supabase/") { - return "supabase/" + lastPart - } - return imageName -} - -func dedupeStrings(values []string) []string { - seen := make(map[string]struct{}, len(values)) - result := make([]string, 0, len(values)) - for _, value := range values { - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - result = append(result, value) - } - return result -} - -func registryFromImage(imageTag string) string { - parts := strings.Split(imageTag, "/") - if len(parts) == 1 { - return "docker.io" - } - first := parts[0] - if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" { - return first - } - return "docker.io" -} - -func DockerImagePull(ctx context.Context, imageTag string, w io.Writer) error { - out, err := Docker.ImagePull(ctx, imageTag, image.PullOptions{ - RegistryAuth: GetRegistryAuthForImage(imageTag), - }) - if err != nil { - return errors.Errorf("failed to pull docker image: %w", err) - } - defer out.Close() - if err := jsonmessage.DisplayJSONMessagesToStream(out, streams.NewOut(w), nil); err != nil { - return errors.Errorf("failed to display json stream: %w", err) - } - return nil -} - -// Used by unit tests -var timeUnit = time.Second - -func DockerImagePullWithRetry(ctx context.Context, image string, retries int) error { - err := DockerImagePull(ctx, image, os.Stderr) - for i := range retries { - if err == nil || errors.Is(ctx.Err(), context.Canceled) { - break - } - fmt.Fprintln(os.Stderr, err) - period := time.Duration(2<<(i+1)) * timeUnit - fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image) - time.Sleep(period) - err = DockerImagePull(ctx, image, os.Stderr) - } - return err -} - -func DockerPullImageIfNotCached(ctx context.Context, imageName string) error { - _, err := DockerResolveImageIfNotCached(ctx, imageName) - return err -} - -func DockerResolveImageIfNotCached(ctx context.Context, imageName string) (string, error) { - imageUrls := GetRegistryImageUrls(imageName) - for _, imageUrl := range imageUrls { - if _, err := Docker.ImageInspect(ctx, imageUrl); err == nil { - return imageUrl, nil - } else if !errdefs.IsNotFound(err) { - return "", errors.Errorf("failed to inspect docker image: %w", err) - } - } - var pullErrors []error - for _, imageUrl := range imageUrls { - err := DockerImagePullWithRetry(ctx, imageUrl, 2) - if err == nil { - return imageUrl, nil - } - pullErrors = append(pullErrors, fmt.Errorf("%s: %w", imageUrl, err)) - } - // errors.Join keeps every candidate's cause chain, so DockerStart's - // client.IsErrConnectionFailed unwrap still fires (errors.As traverses the - // Unwrap() []error tree) without flattening causes to a string or - // duplicating a representative error in the message. - return "", errors.Errorf("failed to pull docker image from all registries: %w", errors.Join(pullErrors...)) -} - -var suggestDockerInstall = "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop" - -// SuggestDockerInstallIfConnectionFailed sets the install hint when err is a -// Docker daemon connection failure. Callers that resolve images before -// DockerStart (e.g. the concurrent ensureImagesCached pre-pull) call this once -// on the aggregated error, so the hint is surfaced without racing on the -// CmdSuggestion global from multiple goroutines. -func SuggestDockerInstallIfConnectionFailed(err error) { - if client.IsErrConnectionFailed(err) { - CmdSuggestion = suggestDockerInstall - } -} - -func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { - // Pull container image - imageUrl, err := DockerResolveImageIfNotCached(ctx, config.Image) - if err != nil { - SuggestDockerInstallIfConnectionFailed(err) - return "", err - } - // Setup default config - config.Image = imageUrl - if config.Labels == nil { - config.Labels = make(map[string]string, 2) - } - config.Labels[CliProjectLabel] = Config.ProjectId - config.Labels[composeProjectLabel] = Config.ProjectId - // Configure container network - hostConfig.ExtraHosts = append(hostConfig.ExtraHosts, extraHosts...) - if networkId := viper.GetString("network-id"); len(networkId) > 0 { - hostConfig.NetworkMode = container.NetworkMode(networkId) - } else if len(hostConfig.NetworkMode) == 0 { - hostConfig.NetworkMode = container.NetworkMode(NetId) - } - if err := DockerNetworkCreateIfNotExists(ctx, hostConfig.NetworkMode, config.Labels); err != nil { - return "", err - } - // Configure container volumes - var binds, sources []string - for _, bind := range hostConfig.Binds { - spec, err := loader.ParseVolume(bind) - if err != nil { - return "", errors.Errorf("failed to parse docker volume: %w", err) - } - if spec.Type != string(mount.TypeVolume) { - binds = append(binds, bind) - } else if len(spec.Source) > 0 { - sources = append(sources, spec.Source) - } - } - // Skip named volume for BitBucket pipeline - if os.Getenv("BITBUCKET_CLONE_DIR") != "" { - hostConfig.Binds = binds - // Bitbucket doesn't allow for --security-opt option to be set - // https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands - hostConfig.SecurityOpt = nil - } else { - // Create named volumes with labels - for _, name := range sources { - if _, err := Docker.VolumeCreate(ctx, volume.CreateOptions{ - Name: name, - Labels: config.Labels, - }); err != nil { - return "", errors.Errorf("failed to create volume: %w", err) - } - } - } - // Create container from image - resp, err := Docker.ContainerCreate(ctx, &config, &hostConfig, &networkingConfig, nil, containerName) - if err != nil { - return "", errors.Errorf("failed to create docker container: %w", err) - } - // Run container in background - err = Docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) - if err != nil { - if hostPort := parsePortBindError(err); len(hostPort) > 0 { - CmdSuggestion = suggestDockerStop(ctx, hostPort) - prefix := "Or configure" - if len(CmdSuggestion) == 0 { - prefix = "Try configuring" - } - name := containerName - if endpoint, ok := networkingConfig.EndpointsConfig[NetId]; ok && len(endpoint.Aliases) > 0 { - name = endpoint.Aliases[0] - } - CmdSuggestion += fmt.Sprintf("\n%s a different %s port in %s", prefix, name, Bold(ConfigPath)) - } - err = errors.Errorf("failed to start docker container %q: %w", containerName, err) - } - return resp.ID, err -} - -func DockerRemove(containerId string) { - if err := Docker.ContainerRemove(context.Background(), containerId, container.RemoveOptions{ - RemoveVolumes: true, - Force: true, - }); err != nil { - fmt.Fprintln(os.Stderr, "Failed to remove container:", containerId, err) - } -} - -type DockerJob struct { - Image string - Env []string - Cmd []string -} - -func DockerRunJob(ctx context.Context, job DockerJob, stdout, stderr io.Writer) error { - return DockerRunOnceWithStream(ctx, job.Image, job.Env, job.Cmd, stdout, stderr) -} - -// Runs a container image exactly once, returning stdout and throwing error on non-zero exit code. -func DockerRunOnce(ctx context.Context, image string, env []string, cmd []string) (string, error) { - stderr := GetDebugLogger() - var out bytes.Buffer - err := DockerRunOnceWithStream(ctx, image, env, cmd, &out, stderr) - return out.String(), err -} - -func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd []string, stdout, stderr io.Writer) error { - return DockerRunOnceWithConfig(ctx, container.Config{ - Image: image, - Env: env, - Cmd: cmd, - }, container.HostConfig{}, network.NetworkingConfig{}, "", stdout, stderr) -} - -func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { - // Cannot rely on docker's auto remove because - // 1. We must inspect exit code after container stops - // 2. Context cancellation may happen after start - container, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) - if err != nil { - return err - } - defer DockerRemove(container) - return DockerStreamLogs(ctx, container, stdout, stderr) -} - -// DockerRunOnceWaitWithConfig is like DockerRunOnceWithConfig but waits for the -// container to exit and then reads its already-buffered logs WITHOUT following -// the stream. -// -// DockerRunOnceWithConfig detects completion by reading a follow=true log stream -// until EOF. That EOF never arrives under podman: its /containers//logs?follow -// endpoint does not close the response when the container stops, so -// stdcopy.StdCopy blocks on the chunked HTTP body forever and the caller hangs at -// 0% CPU (supabase/pg-toolbelt#312). Waiting on /wait for the exit code and then -// reading the log once is reliable on both Docker and podman. -// -// Use this only for short-lived containers with bounded output (e.g. the -// edge-runtime pg-delta scripts): the full log is read after exit rather than -// streamed, so it is not suitable for large streaming output such as db dump. -func DockerRunOnceWaitWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { - containerId, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) - if err != nil { - return err - } - defer DockerRemove(containerId) - exitCode, err := dockerWaitExit(ctx, containerId) - if err != nil { - return err - } - if err := DockerStreamLogsOnce(ctx, containerId, stdout, stderr); err != nil { - return err - } - switch exitCode { - case 0: - return nil - case 137: - err = ErrContainerKilled - default: - err = errors.Errorf("exit %d", exitCode) - } - return errors.Errorf("error running container: %w", err) -} - -// dockerWaitInterval is how often dockerWaitExit polls container state. Kept -// short so bounded one-shot scripts return promptly; it is a package var so -// tests can drop it to zero. -var dockerWaitInterval = 200 * time.Millisecond - -// dockerWaitExit polls container state until it stops and returns its exit code. -// -// It deliberately uses ContainerInspect rather than a followed log stream (or -// /wait) to detect completion: inspect is reliable under podman, whereas -// podman's /logs?follow endpoint does not close when the container stops, which -// is what hangs DockerStreamLogs (see DockerRunOnceWaitWithConfig). Reusing -// inspect also keeps the request surface identical to DockerStreamLogs, so the -// existing test mocks continue to apply. -func dockerWaitExit(ctx context.Context, containerId string) (int64, error) { - for { - resp, err := Docker.ContainerInspect(ctx, containerId) - if err != nil { - return 0, errors.Errorf("failed to inspect docker container: %w", err) - } - if resp.State != nil && !resp.State.Running { - return int64(resp.State.ExitCode), nil - } - select { - case <-ctx.Done(): - return 0, ctx.Err() - case <-time.After(dockerWaitInterval): - } - } -} - -var ErrContainerKilled = errors.New("exit 137") - -func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { - logsOptions := container.LogsOptions{ - ShowStdout: true, - ShowStderr: true, - Follow: true, - } - for _, apply := range opts { - apply(&logsOptions) - } - // Stream logs - logs, err := Docker.ContainerLogs(ctx, containerId, logsOptions) - if err != nil { - return errors.Errorf("failed to read docker logs: %w", err) - } - defer logs.Close() - if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil { - return errors.Errorf("failed to copy docker logs: %w", err) - } - // Check exit code - resp, err := Docker.ContainerInspect(ctx, containerId) - if err != nil { - return errors.Errorf("failed to inspect docker container: %w", err) - } - switch resp.State.ExitCode { - case 0: - return nil - case 137: - err = ErrContainerKilled - default: - err = errors.Errorf("exit %d", resp.State.ExitCode) - } - return errors.Errorf("error running container: %w", err) -} - -func DockerStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error { - logs, err := Docker.ContainerLogs(ctx, containerId, container.LogsOptions{ - ShowStdout: true, - ShowStderr: true, - }) - if err != nil { - return errors.Errorf("failed to read docker logs: %w", err) - } - defer logs.Close() - if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil { - return errors.Errorf("failed to copy docker logs: %w", err) - } - return nil -} - -// Exec a command once inside a container, returning stdout and throwing error on non-zero exit code. -func DockerExecOnce(ctx context.Context, containerId string, env []string, cmd []string) (string, error) { - stderr := io.Discard - if viper.GetBool("DEBUG") { - stderr = os.Stderr - } - var out bytes.Buffer - err := DockerExecOnceWithStream(ctx, containerId, "", env, cmd, &out, stderr) - return out.String(), err -} - -func DockerExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error { - // Reset shadow database - exec, err := Docker.ContainerExecCreate(ctx, containerId, container.ExecOptions{ - Env: env, - Cmd: cmd, - WorkingDir: workdir, - AttachStderr: true, - AttachStdout: true, - }) - if err != nil { - return errors.Errorf("failed to exec docker create: %w", err) - } - // Read exec output - resp, err := Docker.ContainerExecAttach(ctx, exec.ID, container.ExecStartOptions{}) - if err != nil { - return errors.Errorf("failed to exec docker attach: %w", err) - } - defer resp.Close() - // Capture error details - if _, err := stdcopy.StdCopy(stdout, stderr, resp.Reader); err != nil { - return errors.Errorf("failed to copy docker logs: %w", err) - } - // Get the exit code - iresp, err := Docker.ContainerExecInspect(ctx, exec.ID) - if err != nil { - return errors.Errorf("failed to exec docker inspect: %w", err) - } - if iresp.ExitCode > 0 { - err = errors.New("error executing command") - } - return err -} - -func IsDockerRunning(ctx context.Context) bool { - _, err := Docker.Ping(ctx) - return !client.IsErrConnectionFailed(err) -} - -var portErrorPattern = regexp.MustCompile("Bind for (.*) failed: port is already allocated") - -func parsePortBindError(err error) string { - matches := portErrorPattern.FindStringSubmatch(err.Error()) - if len(matches) > 1 { - return matches[len(matches)-1] - } - return "" -} - -func suggestDockerStop(ctx context.Context, hostPort string) string { - if containers, err := Docker.ContainerList(ctx, container.ListOptions{}); err == nil { - for _, c := range containers { - for _, p := range c.Ports { - if fmt.Sprintf("%s:%d", p.IP, p.PublicPort) == hostPort { - if project, ok := c.Labels[CliProjectLabel]; ok { - return "\nTry stopping the running project with " + Aqua("supabase stop --project-id "+project) - } else { - name := c.ID - if len(c.Names) > 0 { - name = c.Names[0] - } - return "\nTry stopping the running container with " + Aqua("docker stop "+name) - } - } - } - } - } - return "" -} diff --git a/apps/cli-go/internal/utils/docker_darwin.go b/apps/cli-go/internal/utils/docker_darwin.go deleted file mode 100644 index af183bc31a..0000000000 --- a/apps/cli-go/internal/utils/docker_darwin.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build darwin - -package utils - -import "github.com/docker/docker/api/types/container" - -var extraHosts []string - -func isUserDefined(mode container.NetworkMode) bool { - return mode.IsUserDefined() -} diff --git a/apps/cli-go/internal/utils/docker_linux.go b/apps/cli-go/internal/utils/docker_linux.go deleted file mode 100644 index b5463d0604..0000000000 --- a/apps/cli-go/internal/utils/docker_linux.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build linux - -package utils - -import "github.com/docker/docker/api/types/container" - -// Allows containers to resolve host network: https://stackoverflow.com/a/62431165 -var extraHosts = []string{DinDHost + ":host-gateway"} - -func isUserDefined(mode container.NetworkMode) bool { - return mode.IsUserDefined() -} diff --git a/apps/cli-go/internal/utils/docker_test.go b/apps/cli-go/internal/utils/docker_test.go deleted file mode 100644 index 5c58d5aea0..0000000000 --- a/apps/cli-go/internal/utils/docker_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "net/http" - "testing" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/pkg/stdcopy" - "github.com/h2non/gock" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" -) - -const ( - containerId = "test-container" - imageId = "test-image" -) - -func TestPullImage(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - - t.Run("pulls image if missing", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusNotFound) - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", imageId). - MatchParam("tag", "latest"). - Reply(http.StatusAccepted) - // Run test - assert.NoError(t, DockerPullImageIfNotCached(context.Background(), imageId)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("does nothing if image exists", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusOK). - JSON(image.InspectResponse{}) - // Run test - assert.NoError(t, DockerPullImageIfNotCached(context.Background(), imageId)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error if docker is unavailable", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusServiceUnavailable) - // Run test - assert.Error(t, DockerPullImageIfNotCached(context.Background(), imageId)) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to pull image", func(t *testing.T) { - timeUnit = time.Duration(0) - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusNotFound) - // Total 3 tries - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", imageId). - MatchParam("tag", "latest"). - Reply(http.StatusServiceUnavailable) - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", imageId). - MatchParam("tag", "latest"). - Reply(http.StatusAccepted). - JSON(jsonmessage.JSONMessage{Error: &jsonmessage.JSONError{Message: "toomanyrequests"}}) - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", imageId). - MatchParam("tag", "latest"). - Reply(http.StatusAccepted). - JSON(jsonmessage.JSONMessage{Error: &jsonmessage.JSONError{Message: "no space left on device"}}) - // Run test - err := DockerPullImageIfNotCached(context.Background(), imageId) - // Validate api - assert.ErrorContains(t, err, "no space left on device") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("falls back to GHCR when the default ECR pull fails", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "") - timeUnit = time.Duration(0) - t.Cleanup(func() { - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - timeUnit = time.Second - }) - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - ecrImage := "public.ecr.aws/supabase/" + imageId - ghcrImage := "ghcr.io/supabase/" + imageId - dockerImage := imageId - for _, image := range []string{ecrImage, ghcrImage, dockerImage} { - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + image + "/json"). - Reply(http.StatusNotFound) - } - // ECR gets the initial attempt plus two retries. - for range 3 { - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", ecrImage). - MatchParam("tag", "latest"). - Reply(http.StatusAccepted). - JSON(jsonmessage.JSONMessage{Error: &jsonmessage.JSONError{Message: "toomanyrequests: Rate exceeded"}}) - } - gock.New(Docker.DaemonHost()). - Post("/v"+Docker.ClientVersion()+"/images/create"). - MatchParam("fromImage", ghcrImage). - MatchParam("tag", "latest"). - Reply(http.StatusAccepted) - // Run test - imageURL, err := DockerResolveImageIfNotCached(context.Background(), imageId) - // Validate api - assert.NoError(t, err) - assert.Equal(t, ghcrImage, imageURL) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestRegistryImageUrls(t *testing.T) { - t.Cleanup(func() { - viper.Set("INTERNAL_IMAGE_REGISTRY", "") - }) - - t.Run("returns fallback candidates when the registry is unset", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "") - - assert.Equal(t, []string{ - "public.ecr.aws/supabase/postgres:17.6.1.138", - "ghcr.io/supabase/postgres:17.6.1.138", - "supabase/postgres:17.6.1.138", - }, GetRegistryImageUrls("supabase/postgres:17.6.1.138")) - }) - - t.Run("dedupes an already-defaulted image", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "") - - assert.Equal(t, []string{ - "public.ecr.aws/supabase/postgres:17.6.1.138", - "ghcr.io/supabase/postgres:17.6.1.138", - "supabase/postgres:17.6.1.138", - }, GetRegistryImageUrls("public.ecr.aws/supabase/postgres:17.6.1.138")) - }) - - t.Run("uses a single candidate when the registry is explicitly configured", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "public.ecr.aws") - assert.Equal(t, []string{ - "public.ecr.aws/supabase/postgres:17.6.1.138", - }, GetRegistryImageUrls("supabase/postgres:17.6.1.138")) - - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - assert.Equal(t, []string{ - "supabase/postgres:17.6.1.138", - }, GetRegistryImageUrls("supabase/postgres:17.6.1.138")) - }) -} - -func TestRunOnce(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - - t.Run("runs once in container", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageId, containerId) - require.NoError(t, apitest.MockDockerLogs(Docker, containerId, "hello world")) - // Run test - out, err := DockerRunOnce(context.Background(), imageId, nil, nil) - assert.NoError(t, err) - // Validate api - assert.Equal(t, "hello world", out) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on container create", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusOK). - JSON(image.InspectResponse{}) - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/networks/create"). - Reply(http.StatusCreated). - JSON(network.CreateResponse{}) - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/containers/create"). - Reply(http.StatusServiceUnavailable) - // Run test - _, err := DockerRunOnce(context.Background(), imageId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on container start", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/images/" + imageId + "/json"). - Reply(http.StatusOK). - JSON(image.InspectResponse{}) - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/networks/create"). - Reply(http.StatusCreated). - JSON(network.CreateResponse{}) - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/containers/create"). - Reply(http.StatusOK). - JSON(container.CreateResponse{ID: containerId}) - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/containers/" + containerId + "/start"). - Reply(http.StatusServiceUnavailable) - // Run test - _, err := DockerRunOnce(context.Background(), imageId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("removes container on cancel", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageId, containerId) - gock.New(Docker.DaemonHost()). - Get("/v"+Docker.ClientVersion()+"/containers/"+containerId+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Delay(1 * time.Second) - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(200*time.Millisecond)) - defer cancel() - gock.New(Docker.DaemonHost()). - Delete("/v" + Docker.ClientVersion() + "/containers/" + containerId). - Reply(http.StatusOK) - // Run test - _, err := DockerRunOnce(ctx, imageId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to parse logs", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageId, containerId) - gock.New(Docker.DaemonHost()). - Get("/v"+Docker.ClientVersion()+"/containers/"+containerId+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - BodyString("hello world") - gock.New(Docker.DaemonHost()). - Delete("/v" + Docker.ClientVersion() + "/containers/" + containerId). - Reply(http.StatusOK) - // Run test - _, err := DockerRunOnce(context.Background(), imageId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to inspect", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageId, containerId) - // Setup docker style logs - var body bytes.Buffer - writer := stdcopy.NewStdWriter(&body, stdcopy.Stdout) - _, err := writer.Write([]byte("hello world")) - require.NoError(t, err) - gock.New("http:///var/run/docker.sock"). - Get("/v"+Docker.ClientVersion()+"/containers/"+containerId+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Body(&body) - gock.New("http:///var/run/docker.sock"). - Get("/v" + Docker.ClientVersion() + "/containers/" + containerId + "/json"). - Reply(http.StatusServiceUnavailable) - gock.New(Docker.DaemonHost()). - Delete("/v" + Docker.ClientVersion() + "/containers/" + containerId). - Reply(http.StatusOK) - // Run test - _, err = DockerRunOnce(context.Background(), imageId, nil, nil) - assert.ErrorContains(t, err, "request returned 503 Service Unavailable for API route and version") - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on non-zero exit code", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageId, containerId) - // Setup docker style logs - var body bytes.Buffer - writer := stdcopy.NewStdWriter(&body, stdcopy.Stdout) - _, err := writer.Write([]byte("hello world")) - require.NoError(t, err) - gock.New("http:///var/run/docker.sock"). - Get("/v"+Docker.ClientVersion()+"/containers/"+containerId+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Body(&body) - gock.New("http:///var/run/docker.sock"). - Get("/v" + Docker.ClientVersion() + "/containers/" + containerId + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - ExitCode: 1, - }}}) - gock.New(Docker.DaemonHost()). - Delete("/v" + Docker.ClientVersion() + "/containers/" + containerId). - Reply(http.StatusOK) - // Run test - _, err = DockerRunOnce(context.Background(), imageId, nil, nil) - assert.ErrorContains(t, err, "error running container: exit 1") - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestExecOnce(t *testing.T) { - t.Run("throws error on failure to exec", func(t *testing.T) { - // Setup mock server - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/containers/" + containerId + "/exec"). - Reply(http.StatusServiceUnavailable) - // Run test - _, err := DockerExecOnce(context.Background(), containerId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to hijack", func(t *testing.T) { - // Setup mock server - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - gock.New(Docker.DaemonHost()). - Post("/v" + Docker.ClientVersion() + "/containers/" + containerId + "/exec"). - Reply(http.StatusAccepted). - JSON(container.ExecCreateResponse{ID: "test-command"}) - // Run test - _, err := DockerExecOnce(context.Background(), containerId, nil, nil) - assert.Error(t, err) - // Validate api - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - // TODO: mock tcp hijack -} diff --git a/apps/cli-go/internal/utils/docker_wait_test.go b/apps/cli-go/internal/utils/docker_wait_test.go deleted file mode 100644 index 22ee32e0ee..0000000000 --- a/apps/cli-go/internal/utils/docker_wait_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" -) - -// DockerRunOnceWaitWithConfig must detect container completion via inspect and -// read logs without following the stream. Following the log stream to detect -// exit hangs forever under podman, whose /logs?follow endpoint never closes when -// the container stops (supabase/pg-toolbelt#312). These tests pin that the runner -// reads the captured output and maps the inspected exit code to an error. -func TestDockerRunOnceWait(t *testing.T) { - imageUrl := GetRegistryImageUrl(imageId) - - t.Run("waits for exit then reads buffered logs", func(t *testing.T) { - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(Docker, containerId, "CATALOG\n")) - // Run test - var stdout, stderr bytes.Buffer - err := DockerRunOnceWaitWithConfig( - context.Background(), - container.Config{Image: imageUrl}, - container.HostConfig{}, - network.NetworkingConfig{}, - containerId, - &stdout, - &stderr, - ) - // Validate - assert.NoError(t, err) - assert.Equal(t, "CATALOG\n", stdout.String()) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("maps non-zero exit code to error", func(t *testing.T) { - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogsExitCode(Docker, containerId, 1)) - // Run test - var stdout, stderr bytes.Buffer - err := DockerRunOnceWaitWithConfig( - context.Background(), - container.Config{Image: imageUrl}, - container.HostConfig{}, - network.NetworkingConfig{}, - containerId, - &stdout, - &stderr, - ) - // Validate - assert.ErrorContains(t, err, "error running container: exit 1") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/utils/docker_windows.go b/apps/cli-go/internal/utils/docker_windows.go deleted file mode 100644 index 99e68d9b69..0000000000 --- a/apps/cli-go/internal/utils/docker_windows.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build windows - -package utils - -import ( - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" -) - -var extraHosts []string - -func isUserDefined(mode container.NetworkMode) bool { - // Host network requires explicit check on windows: https://github.com/supabase/cli/pull/952 - return mode.IsUserDefined() && mode.UserDefined() != network.NetworkHost -} diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go deleted file mode 100644 index 8e54afa628..0000000000 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ /dev/null @@ -1,164 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "fmt" - "net" - "strings" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/go-errors/errors" - "github.com/spf13/viper" -) - -// EdgeRuntimeScriptErrorSentinel is printed to stderr by the pg-delta Deno -// templates when their body throws (see the `catch` blocks in -// internal/db/diff/templates/*.ts). The templates force the edge-runtime worker -// to exit by throwing on both the success and failure paths, and that non-zero -// exit is otherwise suppressed here when stderr contains "main worker has been -// destroyed". Without a distinct marker a crashed script would be -// indistinguishable from a successful empty diff, so `db pull` would report "No -// schema changes found" while the real error (e.g. a permission-denied catalog -// query) was silently swallowed. Templates and tests must reference this exact -// string. See supabase/cli#5826. -const EdgeRuntimeScriptErrorSentinel = "PGDELTA_SCRIPT_ERROR" - -// edgeRuntimeFile is a single file dropped into the edge-runtime container's -// working directory before the configured command is run. -type edgeRuntimeFile struct { - name string - content string -} - -// edgeRuntimeOptions accumulates the optional inputs assembled by -// EdgeRuntimeOption functions and consumed by RunEdgeRuntimeScript. -type edgeRuntimeOptions struct { - extraFiles []edgeRuntimeFile - extraEnv []string -} - -// EdgeRuntimeOption customizes a RunEdgeRuntimeScript invocation. The current -// shape (extra files dropped alongside index.ts, extra container env vars) -// covers the local-pg-delta use case; extend the option struct as new needs -// arrive instead of adding more positional arguments. -type EdgeRuntimeOption func(*edgeRuntimeOptions) - -// WithExtraFile schedules an extra file alongside `index.ts` in the container. -// Useful for project-local config files (e.g. `.npmrc`, `deno.json`) that need -// to live next to the script Deno is asked to run. -func WithExtraFile(name, content string) EdgeRuntimeOption { - return func(o *edgeRuntimeOptions) { - o.extraFiles = append(o.extraFiles, edgeRuntimeFile{name: name, content: content}) - } -} - -// WithExtraEnv appends container env entries in `KEY=value` form. -func WithExtraEnv(entries ...string) EdgeRuntimeOption { - return func(o *edgeRuntimeOptions) { - o.extraEnv = append(o.extraEnv, entries...) - } -} - -// getFreeHostPort asks the OS for an unused TCP port on the host. -func getFreeHostPort() (int, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, errors.Errorf("failed to allocate free port: %w", err) - } - defer listener.Close() - return listener.Addr().(*net.TCPAddr).Port, nil -} - -// EdgeRuntimeStartCmd builds the base command for launching a one-shot Edge -// Runtime script. The runtime's HTTP listener is bound to a free host port so -// concurrent or leftover containers (which share the host network namespace -// because diff containers run with NetworkMode=host) don't collide on the -// edge-runtime default port, which surfaces as "Address already in use (os -// error 98)". See https://github.com/supabase/cli/issues/5407. -func EdgeRuntimeStartCmd() []string { - cmd := []string{"edge-runtime", "start", "--main-service=."} - // Skip the flag on the rare allocation failure to preserve prior behavior. - if port, err := getFreeHostPort(); err == nil { - cmd = append(cmd, fmt.Sprintf("--port=%d", port)) - } - return cmd -} - -// RunEdgeRuntimeScript executes a TypeScript program inside the configured Edge -// Runtime container and streams stdout/stderr back to the caller. -func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, binds []string, errPrefix string, stdout, stderr *bytes.Buffer, opts ...EdgeRuntimeOption) error { - state := &edgeRuntimeOptions{} - for _, opt := range opts { - if opt != nil { - opt(state) - } - } - cmd := EdgeRuntimeStartCmd() - if viper.GetBool("DEBUG") { - cmd = append(cmd, "--verbose") - } - cmdString := strings.Join(cmd, " ") - files := append([]edgeRuntimeFile{{name: "index.ts", content: script}}, state.extraFiles...) - entrypoint := []string{"sh", "-c", buildEdgeRuntimeEntrypoint(files, cmdString)} - combinedEnv := env - if len(state.extraEnv) > 0 { - combinedEnv = append(append([]string{}, env...), state.extraEnv...) - } - // Wait for the container to exit and then read its logs, rather than - // following the log stream to detect completion. The edge-runtime worker is - // forced to exit once the script flushes its output, but podman's - // /logs?follow endpoint does not close when the container stops, so a - // followed read (DockerRunOnceWithConfig) hangs the CLI forever - // (supabase/pg-toolbelt#312). pg-delta script output is bounded, so reading - // the log once after exit is safe. - if err := DockerRunOnceWaitWithConfig( - ctx, - container.Config{ - Image: Config.EdgeRuntime.Image, - Env: combinedEnv, - Entrypoint: entrypoint, - }, - container.HostConfig{ - Binds: binds, - NetworkMode: network.NetworkHost, - }, - network.NetworkingConfig{}, - "", - stdout, - stderr, - ); err != nil && !strings.Contains(stderr.String(), "main worker has been destroyed") { - return errors.Errorf("%s: %w:\n%s", errPrefix, err, stderr.String()) - } - // The templates suppress their own non-zero exit (they throw to force the - // worker to exit, which surfaces as "main worker has been destroyed"), so a - // script crash can slip past the check above. Treat the sentinel — printed - // only by the templates' catch blocks — as a hard failure so the real error, - // collected in stderr, reaches the user instead of looking like an empty diff. - if strings.Contains(stderr.String(), EdgeRuntimeScriptErrorSentinel) { - return errors.Errorf("%s: error running script:\n%s", errPrefix, stderr.String()) - } - return nil -} - -// buildEdgeRuntimeEntrypoint emits a `sh -c` body that writes each file via a -// here-document and then runs cmd. All heredoc openers are joined with `&&` -// before the bodies so bash stacks them in declaration order; each body is -// terminated with a unique sentinel so file contents can contain `EOF` safely. -func buildEdgeRuntimeEntrypoint(files []edgeRuntimeFile, cmd string) string { - if len(files) == 0 { - return cmd + "\n" - } - var head strings.Builder - var bodies strings.Builder - for i, f := range files { - sentinel := fmt.Sprintf("__EDGE_RT_FILE_%d__", i) - fmt.Fprintf(&head, "cat <<'%s' > %s && ", sentinel, f.name) - fmt.Fprintf(&bodies, "%s\n%s\n", f.content, sentinel) - } - head.WriteString(cmd) - head.WriteString("\n") - head.WriteString(bodies.String()) - return head.String() -} diff --git a/apps/cli-go/internal/utils/edgeruntime_test.go b/apps/cli-go/internal/utils/edgeruntime_test.go deleted file mode 100644 index 2497630255..0000000000 --- a/apps/cli-go/internal/utils/edgeruntime_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "io" - "net/http" - "strconv" - "strings" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/pkg/stdcopy" - "github.com/h2non/gock" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" -) - -// mockEdgeRuntimeLogs registers the docker responses RunEdgeRuntimeScript needs: -// a one-shot log read multiplexing stdout+stderr, an inspect reporting the exit -// code, and the container delete. Mirrors apitest.MockDockerErrorLogs but also -// carries stdout so we can assert the success path preserves the script output. -func mockEdgeRuntimeLogs(t *testing.T, containerID, stdout, stderr string, exitCode int) { - t.Helper() - var body bytes.Buffer - if len(stdout) > 0 { - _, err := io.Copy(stdcopy.NewStdWriter(&body, stdcopy.Stdout), strings.NewReader(stdout)) - require.NoError(t, err) - } - if len(stderr) > 0 { - _, err := io.Copy(stdcopy.NewStdWriter(&body, stdcopy.Stderr), strings.NewReader(stderr)) - require.NoError(t, err) - } - gock.New(Docker.DaemonHost()). - Get("/v"+Docker.ClientVersion()+"/containers/"+containerID+"/logs"). - Reply(http.StatusOK). - SetHeader("Content-Type", "application/vnd.docker.raw-stream"). - Body(&body) - gock.New(Docker.DaemonHost()). - Get("/v" + Docker.ClientVersion() + "/containers/" + containerID + "/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ExitCode: exitCode}, - }}) - gock.New(Docker.DaemonHost()). - Delete("/v" + Docker.ClientVersion() + "/containers/" + containerID). - Reply(http.StatusOK) -} - -func TestRunEdgeRuntimeScript(t *testing.T) { - const containerID = "test-edge-runtime" - imageUrl := GetRegistryImageUrl(Config.EdgeRuntime.Image) - - t.Run("surfaces the real error when the script crashes behind the worker-destroyed message", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - t.Cleanup(func() { viper.Set("INTERNAL_IMAGE_REGISTRY", "") }) - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageUrl, containerID) - // The pg-delta template throws to force the worker to exit (surfacing as a - // non-zero exit + "main worker has been destroyed"), and its catch block - // prints the real error and the sentinel. This must NOT look like an empty diff. - stderr := "error: permission denied for table pg_user_mapping\n" + - EdgeRuntimeScriptErrorSentinel + "\n" + - "worker boot error\nmain worker has been destroyed\n" - mockEdgeRuntimeLogs(t, containerID, "", stderr, 1) - - var stdout, stderrBuf bytes.Buffer - err := RunEdgeRuntimeScript(context.Background(), nil, "console.log('x')", nil, "error diffing schema", &stdout, &stderrBuf) - require.Error(t, err) - assert.Contains(t, err.Error(), "error diffing schema: error running script:") - // The real, actionable error must reach the user, not "No schema changes found". - assert.Contains(t, err.Error(), "permission denied for table pg_user_mapping") - }) - - t.Run("still ignores a worker-destroyed exit when no sentinel is present", func(t *testing.T) { - viper.Set("INTERNAL_IMAGE_REGISTRY", "docker.io") - t.Cleanup(func() { viper.Set("INTERNAL_IMAGE_REGISTRY", "") }) - require.NoError(t, apitest.MockDocker(Docker)) - defer gock.OffAll() - apitest.MockDockerStart(Docker, imageUrl, containerID) - // Success path: the template forces the worker to exit after writing output, - // so the exit is non-zero with "main worker has been destroyed" but no sentinel. - mockEdgeRuntimeLogs(t, containerID, "ALTER TABLE x;\n", "main worker has been destroyed\n", 1) - - var stdout, stderrBuf bytes.Buffer - err := RunEdgeRuntimeScript(context.Background(), nil, "console.log('x')", nil, "error diffing schema", &stdout, &stderrBuf) - require.NoError(t, err) - assert.Equal(t, "ALTER TABLE x;\n", stdout.String()) - }) -} - -func TestBuildEdgeRuntimeEntrypoint(t *testing.T) { - t.Run("emits a single heredoc when only the script is provided", func(t *testing.T) { - got := buildEdgeRuntimeEntrypoint( - []edgeRuntimeFile{{name: "index.ts", content: "console.log('hi')"}}, - "edge-runtime start --main-service=.", - ) - assert.True(t, strings.HasPrefix(got, "cat <<'__EDGE_RT_FILE_0__' > index.ts && edge-runtime start --main-service=.\n")) - assert.Contains(t, got, "console.log('hi')\n__EDGE_RT_FILE_0__\n") - }) - - t.Run("chains heredocs in declaration order so each cat reads the matching body", func(t *testing.T) { - got := buildEdgeRuntimeEntrypoint( - []edgeRuntimeFile{ - {name: "index.ts", content: "TS_CONTENT"}, - {name: ".npmrc", content: "NPMRC_CONTENT"}, - }, - "edge-runtime start --main-service=.", - ) - // Both cat declarations must come before any body, separated by &&. - assert.Contains(t, got, "cat <<'__EDGE_RT_FILE_0__' > index.ts && cat <<'__EDGE_RT_FILE_1__' > .npmrc && edge-runtime start --main-service=.") - // Bodies must follow in the same order as the declarations. - idxScript := strings.Index(got, "TS_CONTENT") - idxNpmrc := strings.Index(got, "NPMRC_CONTENT") - require.Greater(t, idxScript, 0) - require.Greater(t, idxNpmrc, idxScript, ".npmrc body must come after index.ts body") - // Sentinels close each body so user content containing `EOF` cannot - // terminate the heredoc early. - assert.Contains(t, got, "TS_CONTENT\n__EDGE_RT_FILE_0__") - assert.Contains(t, got, "NPMRC_CONTENT\n__EDGE_RT_FILE_1__") - assert.True(t, strings.HasSuffix(got, "\n")) - }) - - t.Run("returns just the command when no files are provided", func(t *testing.T) { - got := buildEdgeRuntimeEntrypoint(nil, "edge-runtime start --main-service=.") - assert.Equal(t, "edge-runtime start --main-service=.\n", got) - }) -} - -func TestEdgeRuntimeStartCmd(t *testing.T) { - t.Run("binds an explicit free port", func(t *testing.T) { - cmd := EdgeRuntimeStartCmd() - // Base command must always be present. - assert.Equal(t, []string{"edge-runtime", "start", "--main-service=."}, cmd[:3]) - // A --port flag avoids collisions on the edge-runtime default port (#5407). - var portFlag string - for _, arg := range cmd { - if strings.HasPrefix(arg, "--port=") { - portFlag = arg - } - } - require.NotEmpty(t, portFlag, "expected a --port flag to be set") - port, err := strconv.Atoi(strings.TrimPrefix(portFlag, "--port=")) - require.NoError(t, err) - assert.Greater(t, port, 0) - assert.LessOrEqual(t, port, 65535) - }) - - t.Run("allocates a distinct port per invocation", func(t *testing.T) { - first := getPortArg(t, EdgeRuntimeStartCmd()) - second := getPortArg(t, EdgeRuntimeStartCmd()) - assert.NotEqual(t, first, second) - }) -} - -func getPortArg(t *testing.T, cmd []string) string { - t.Helper() - for _, arg := range cmd { - if strings.HasPrefix(arg, "--port=") { - return arg - } - } - require.FailNow(t, "missing --port flag") - return "" -} diff --git a/apps/cli-go/internal/utils/enum.go b/apps/cli-go/internal/utils/enum.go deleted file mode 100644 index 06cac719d1..0000000000 --- a/apps/cli-go/internal/utils/enum.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "fmt" - "slices" - "strings" - - "github.com/go-errors/errors" -) - -// Ref: https://github.com/spf13/pflag/issues/236#issuecomment-931600452 -type EnumFlag struct { - Allowed []string - Value string -} - -func (a EnumFlag) String() string { - return a.Value -} - -func (a *EnumFlag) Set(p string) error { - if !slices.Contains(a.Allowed, p) { - return errors.Errorf("must be one of [ %s ]", strings.Join(a.Allowed, " | ")) - } - a.Value = p - return nil -} - -func (a *EnumFlag) Type() string { - values := strings.Join(a.Allowed, " | ") - if len(values) < 40 { - return fmt.Sprintf("[ %s ]", values) - } - return "string" -} diff --git a/apps/cli-go/internal/utils/flags/config_path.go b/apps/cli-go/internal/utils/flags/config_path.go deleted file mode 100644 index 4df1bff746..0000000000 --- a/apps/cli-go/internal/utils/flags/config_path.go +++ /dev/null @@ -1,26 +0,0 @@ -package flags - -import ( - "strings" - - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" -) - -func LoadConfig(fsys afero.Fs) error { - utils.Config.ProjectId = ProjectRef - if err := utils.Config.Load("", utils.NewRootFS(fsys)); err != nil { - return err - } - utils.UpdateDockerIds() - // Apply profile specific overrides - if strings.EqualFold(utils.CurrentProfile.Name, "snap") { - ext := utils.Config.Auth.External["snapchat"] - ext.Enabled = true - ext.ClientId = utils.CurrentProfile.AuthClientID - // Any dummy value should work for local dev - ext.Secret.Value = utils.CurrentProfile.AuthClientID - utils.Config.Auth.External["snapchat"] = ext - } - return nil -} diff --git a/apps/cli-go/internal/utils/flags/db_url.go b/apps/cli-go/internal/utils/flags/db_url.go deleted file mode 100644 index bbfda766d6..0000000000 --- a/apps/cli-go/internal/utils/flags/db_url.go +++ /dev/null @@ -1,293 +0,0 @@ -package flags - -import ( - "context" - "crypto/rand" - _ "embed" - "fmt" - "math/big" - "net" - "net/http" - "os" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/credentials" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/config" -) - -type connection int - -const ( - unknown connection = iota - direct - local - linked - proxy -) - -var DbConfig pgconn.Config - -// PoolerFallbackEligible reports whether DbConfig was resolved via the linked -// path, where transparently retrying a failed remote operation through the -// project's IPv4 transaction pooler is safe. Explicit --db-url and --local -// targets must never be silently rerouted, so the fallback stays disabled for -// them. -var PoolerFallbackEligible bool - -func ParseDatabaseConfig(ctx context.Context, flagSet *pflag.FlagSet, fsys afero.Fs) error { - // Changed flags take precedence over default values - var connType connection - if flag := flagSet.Lookup("db-url"); flag != nil && flag.Changed { - connType = direct - } else if flag := flagSet.Lookup("local"); flag != nil && flag.Changed { - connType = local - } else if flag := flagSet.Lookup("linked"); flag != nil && flag.Changed { - connType = linked - } else if flag := flagSet.Lookup("proxy"); flag != nil && flag.Changed { - connType = proxy - } else if value, err := flagSet.GetBool("local"); err == nil && value { - connType = local - } else if value, err := flagSet.GetBool("linked"); err == nil && value { - connType = linked - } else if value, err := flagSet.GetBool("proxy"); err == nil && value { - connType = proxy - } - // Update connection config - switch connType { - case direct: - if err := LoadConfig(fsys); err != nil { - return err - } - if flag := flagSet.Lookup("db-url"); flag != nil { - config, err := pgconn.ParseConfig(flag.Value.String()) - if err != nil { - return errors.Errorf("failed to parse connection string: %w", err) - } - DbConfig = *config - } - case local: - if err := LoadConfig(fsys); err != nil { - return err - } - // Ignore other PG settings - DbConfig.Host = utils.Config.Hostname - DbConfig.Port = utils.Config.Db.Port - DbConfig.User = "postgres" - DbConfig.Password = utils.Config.Db.Password - DbConfig.Database = "postgres" - case linked: - if err := LoadProjectRef(fsys); err != nil { - return err - } - if err := LoadConfig(fsys); err != nil { - return err - } - var err error - if DbConfig, err = NewDbConfigWithPassword(ctx, ProjectRef); err != nil { - return err - } - case proxy: - token, err := utils.LoadAccessTokenFS(fsys) - if err != nil { - return err - } - if err := LoadProjectRef(fsys); err != nil { - return err - } - DbConfig.Host = utils.GetSupabaseAPIHost() - DbConfig.Port = 443 - DbConfig.User = "postgres" - DbConfig.Password = token - DbConfig.Database = ProjectRef - } - // Only the linked path may be transparently rerouted to the IPv4 pooler. - PoolerFallbackEligible = connType == linked - return nil -} - -const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -func RandomString(size int) (string, error) { - data := make([]byte, size) - _, err := rand.Read(data) - if err != nil { - return "", errors.Errorf("failed to read random: %w", err) - } - for i := range data { - n := int(data[i]) % len(letters) - data[i] = letters[n] - } - return string(data), nil -} - -func NewDbConfigWithPassword(ctx context.Context, projectRef string) (pgconn.Config, error) { - config := pgconn.Config{ - Host: utils.GetSupabaseDbHost(projectRef), - Port: 5432, - User: "postgres", - Password: viper.GetString("DB_PASSWORD"), - Database: "postgres", - } - logger := utils.GetDebugLogger() - // Use pooler if host is not reachable directly - d := net.Dialer{Timeout: 5 * time.Second} - addr := fmt.Sprintf("%s:%d", config.Host, config.Port) - if conn, err := d.DialContext(ctx, "tcp", addr); err == nil { - if err := conn.Close(); err != nil { - fmt.Fprintln(logger, err) - } - fmt.Fprintf(logger, "Resolved DNS: %v\n", conn.RemoteAddr()) - } else if poolerConfig := utils.GetPoolerConfig(projectRef); poolerConfig != nil { - if len(config.Password) > 0 { - fmt.Fprintln(logger, "Using database password from env var...") - poolerConfig.Password = config.Password - } else if err := initPoolerLogin(ctx, projectRef, poolerConfig); err != nil { - utils.SetConnectSuggestion(err) - if utils.CmdSuggestion == "" { - utils.CmdSuggestion = utils.SuggestEnvVar - } - return *poolerConfig, err - } - return *poolerConfig, nil - } else { - utils.CmdSuggestion = fmt.Sprintf("Run %s to setup IPv4 connection.", utils.Aqua("supabase link --project-ref "+projectRef)) - return config, errors.Errorf("IPv6 is not supported on your current network: %w", err) - } - // Connect via direct connection - if len(config.Password) > 0 { - fmt.Fprintln(logger, "Using database password from env var...") - } else if err := initLoginRole(ctx, projectRef, &config); err != nil { - // Do not prompt because reading masked input is buggy on windows - utils.CmdSuggestion = utils.SuggestEnvVar - return config, err - } - return config, nil -} - -// ResolvePoolerConfigForFallback returns an authenticated IPv4 transaction -// pooler connection config for the given linked project. It prefers the pooler -// URL persisted at link time and falls back to fetching it from the Management -// API. This is used to transparently retry remote pg operations that failed -// because the direct database host was unreachable over IPv6. -func ResolvePoolerConfigForFallback(ctx context.Context, projectRef string) (pgconn.Config, error) { - poolerConfig := utils.GetPoolerConfig(projectRef) - if poolerConfig == nil { - primary, err := utils.GetPoolerConfigPrimary(ctx, projectRef) - if err != nil { - return pgconn.Config{}, err - } - poolerConfig, err = utils.ParsePoolerURL(primary.ConnectionString) - if err != nil { - return pgconn.Config{}, err - } - // Supavisor transaction mode does not support prepared statements. - poolerConfig.Port = 5432 - } - if password := viper.GetString("DB_PASSWORD"); len(password) > 0 { - poolerConfig.Password = password - } else if err := initPoolerLogin(ctx, projectRef, poolerConfig); err != nil { - return *poolerConfig, err - } - return *poolerConfig, nil -} - -func initLoginRole(ctx context.Context, projectRef string, config *pgconn.Config) error { - fmt.Fprintln(os.Stderr, "Initialising login role...") - body := api.CreateRoleBody{ReadOnly: false} - resp, err := utils.GetSupabase().V1CreateLoginRoleWithResponse(ctx, projectRef, body) - if err != nil { - return errors.Errorf("failed to initialise login role: %w", err) - } else if resp.JSON201 == nil { - return errors.Errorf("unexpected login role status %d: %s", resp.StatusCode(), string(resp.Body)) - } - config.User = resp.JSON201.Role - config.Password = resp.JSON201.Password - return nil -} - -func initPoolerLogin(ctx context.Context, projectRef string, poolerConfig *pgconn.Config) error { - poolerUser := poolerConfig.User - if err := initLoginRole(ctx, projectRef, poolerConfig); err != nil { - return err - } - suffix := "." + projectRef - if strings.HasSuffix(poolerUser, suffix) { - poolerConfig.User += suffix - } - // Wait for pooler to refresh password - login := func() error { - conn, err := pgconn.ConnectConfig(ctx, poolerConfig) - if err != nil { - return errors.Errorf("failed to connect as temp role: %w", err) - } - return conn.Close(ctx) - } - notify := utils.NewErrorCallback(func(attempt uint) error { - if attempt < 3 { - return nil - } - if ips, err := ListNetworkBans(ctx, projectRef); err != nil { - return err - } else if len(ips) > 0 { - return UnbanIP(ctx, projectRef, ips...) - } - return nil - }) - return backoff.RetryNotify(login, utils.NewBackoffPolicy(ctx), notify) -} - -func ListNetworkBans(ctx context.Context, projectRef string) ([]string, error) { - resp, err := utils.GetSupabase().V1ListAllNetworkBansWithResponse(ctx, projectRef) - if err != nil { - return nil, errors.Errorf("failed to list network bans: %w", err) - } else if resp.JSON201 == nil { - return nil, errors.Errorf("unexpected list bans status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return resp.JSON201.BannedIpv4Addresses, nil -} - -func UnbanIP(ctx context.Context, projectRef string, addrs ...string) error { - includeSelf := len(addrs) == 0 - body := api.RemoveNetworkBanRequest{ - Ipv4Addresses: append([]string{}, addrs...), - RequesterIp: &includeSelf, - } - if resp, err := utils.GetSupabase().V1DeleteNetworkBansWithResponse(ctx, projectRef, body); err != nil { - return errors.Errorf("failed to remove network bans: %w", err) - } else if resp.StatusCode() != http.StatusOK { - return errors.Errorf("unexpected unban status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil -} - -const PASSWORD_LENGTH = 16 - -func PromptPassword(stdin *os.File) string { - fmt.Fprint(os.Stderr, "Enter your database password (or leave blank to generate one): ") - if input := credentials.PromptMasked(stdin); len(input) > 0 { - return input - } - // Generate a password, see ./Settings/Database/DatabaseSettings/ResetDbPassword.tsx#L83 - var password []byte - charset := string(config.LowerUpperLettersDigits.ToChar()) - charset = strings.ReplaceAll(charset, ":", "") - maxRange := big.NewInt(int64(len(charset))) - for range PASSWORD_LENGTH { - random, err := rand.Int(rand.Reader, maxRange) - if err != nil { - fmt.Fprintln(os.Stderr, "Failed to randomise password:", err) - continue - } - password = append(password, charset[random.Int64()]) - } - return string(password) -} diff --git a/apps/cli-go/internal/utils/flags/db_url_test.go b/apps/cli-go/internal/utils/flags/db_url_test.go deleted file mode 100644 index 7343be6519..0000000000 --- a/apps/cli-go/internal/utils/flags/db_url_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package flags - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "testing" - - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" -) - -func TestParseDatabaseConfig(t *testing.T) { - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - t.Run("parses direct connection from db-url flag", func(t *testing.T) { - flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) - flagSet.String("db-url", "postgres://postgres:password@localhost:5432/postgres", "") - err := flagSet.Set("db-url", "postgres://admin:secret@db.example.com:6432/app") - assert.Nil(t, err) - - fsys := afero.NewMemMapFs() - - err = ParseDatabaseConfig(context.Background(), flagSet, fsys) - - assert.NoError(t, err) - assert.Equal(t, "db.example.com", DbConfig.Host) - assert.Equal(t, uint16(6432), DbConfig.Port) - assert.Equal(t, "admin", DbConfig.User) - assert.Equal(t, "secret", DbConfig.Password) - assert.Equal(t, "app", DbConfig.Database) - }) - - t.Run("parses local connection", func(t *testing.T) { - flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) - flagSet.Bool("local", false, "") - err := flagSet.Set("local", "true") - assert.Nil(t, err) - - fsys := afero.NewMemMapFs() - - utils.Config.Hostname = "localhost" - utils.Config.Db.Port = 54322 - utils.Config.Db.Password = "local-password" - - err = ParseDatabaseConfig(context.Background(), flagSet, fsys) - - assert.NoError(t, err) - assert.Equal(t, "localhost", DbConfig.Host) - assert.Equal(t, uint16(54322), DbConfig.Port) - assert.Equal(t, "postgres", DbConfig.User) - assert.Equal(t, "local-password", DbConfig.Password) - assert.Equal(t, "postgres", DbConfig.Database) - }) - - t.Run("parses linked connection", func(t *testing.T) { - flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) - flagSet.Bool("linked", false, "") - err := flagSet.Set("linked", "true") - assert.Nil(t, err) - - fsys := afero.NewMemMapFs() - - project := apitest.RandomProjectRef() - err = afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) - require.NoError(t, err) - - dbURL := fmt.Sprintf("postgres://postgres:postgres@db.%s.supabase.co:6543/postgres", project) - err = afero.WriteFile(fsys, utils.PoolerUrlPath, []byte(dbURL), 0644) - require.NoError(t, err) - - viper.Set("DB_PASSWORD", "test") - err = ParseDatabaseConfig(context.Background(), flagSet, fsys) - - assert.NoError(t, err) - assert.True(t, strings.HasPrefix(DbConfig.Host, utils.GetSupabaseDbHost(project))) - }) -} - -func TestResolvePoolerConfigForFallback(t *testing.T) { - ref := apitest.RandomProjectRef() - - t.Run("uses linked pooler url with db password", func(t *testing.T) { - utils.Config.Db.Pooler.ConnectionString = "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" - viper.Set("DB_PASSWORD", "secret") - t.Cleanup(func() { - utils.Config.Db.Pooler.ConnectionString = "" - viper.Set("DB_PASSWORD", "") - }) - - config, err := ResolvePoolerConfigForFallback(context.Background(), ref) - - require.NoError(t, err) - assert.Equal(t, "aws-0-us-east-1.pooler.supabase.com", config.Host) - assert.Equal(t, uint16(5432), config.Port) - assert.Equal(t, "postgres."+ref, config.User) - assert.Equal(t, "secret", config.Password) - }) - - t.Run("falls back to api pooler config", func(t *testing.T) { - poolerURL := "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres" - utils.Config.Db.Pooler.ConnectionString = "" - viper.Set("DB_PASSWORD", "secret") - t.Cleanup(func() { viper.Set("DB_PASSWORD", "") }) - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + ref + "/config/database/pooler"). - Reply(http.StatusOK). - JSON([]api.SupavisorConfigResponseOutput{{ - DatabaseType: api.SupavisorConfigResponseOutputDatabaseTypePRIMARY, - ConnectionString: poolerURL, - }}) - - config, err := ResolvePoolerConfigForFallback(context.Background(), ref) - - require.NoError(t, err) - assert.Equal(t, "aws-0-eu-west-1.pooler.supabase.com", config.Host) - assert.Equal(t, uint16(5432), config.Port) - assert.Equal(t, "secret", config.Password) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("returns error when api pooler is unavailable", func(t *testing.T) { - utils.Config.Db.Pooler.ConnectionString = "" - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + ref + "/config/database/pooler"). - Reply(http.StatusOK). - JSON([]api.SupavisorConfigResponseOutput{}) - - _, err := ResolvePoolerConfigForFallback(context.Background(), ref) - - assert.Error(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestPromptPassword(t *testing.T) { - t.Run("returns user input when provided", func(t *testing.T) { - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - go func() { - defer w.Close() - _, err := w.Write([]byte("test-password")) - assert.Nil(t, err) - }() - - password := PromptPassword(r) - - assert.Equal(t, "test-password", password) - }) - - t.Run("generates password when input is empty", func(t *testing.T) { - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - go func() { - defer w.Close() - _, err := w.Write([]byte("")) - assert.Nil(t, err) - }() - - password := PromptPassword(r) - - assert.Len(t, password, PASSWORD_LENGTH) - assert.NotEqual(t, "", password) - }) -} diff --git a/apps/cli-go/internal/utils/flags/project_ref.go b/apps/cli-go/internal/utils/flags/project_ref.go deleted file mode 100644 index 74d7c9a08e..0000000000 --- a/apps/cli-go/internal/utils/flags/project_ref.go +++ /dev/null @@ -1,80 +0,0 @@ -package flags - -import ( - "bytes" - "context" - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" - "golang.org/x/term" -) - -var ProjectRef string - -func ParseProjectRef(ctx context.Context, fsys afero.Fs) error { - if err := LoadProjectRef(fsys); !errors.Is(err, utils.ErrNotLinked) { - return err - } - // Prompt as the last resort - if term.IsTerminal(int(os.Stdin.Fd())) { - return PromptProjectRef(ctx, "Select a project:") - } - return errors.New(utils.ErrNotLinked) -} - -func PromptProjectRef(ctx context.Context, title string, opts ...tea.ProgramOption) error { - resp, err := utils.GetSupabase().V1ListAllProjectsWithResponse(ctx) - if err != nil { - return errors.Errorf("failed to retrieve projects: %w", err) - } - if resp.JSON200 == nil { - return errors.New("Unexpected error retrieving projects: " + string(resp.Body)) - } - items := make([]utils.PromptItem, len(*resp.JSON200)) - for i, project := range *resp.JSON200 { - items[i] = utils.PromptItem{ - Summary: project.Id, - Details: fmt.Sprintf("name: %s, org: %s, region: %s", project.Name, project.OrganizationSlug, project.Region), - } - } - choice, err := utils.PromptChoice(ctx, title, items, opts...) - if err != nil { - return err - } - ProjectRef = choice.Summary - fmt.Fprintln(os.Stderr, "Selected project:", ProjectRef) - return nil -} - -func LoadProjectRef(fsys afero.Fs) error { - debuglogger := utils.GetDebugLogger() - // Flag takes highest precedence - if len(ProjectRef) > 0 { - fmt.Fprintf(debuglogger, "Loading project ref from flag: %s\n", ProjectRef) - return utils.AssertProjectRefIsValid(ProjectRef) - } - // Env var takes precedence over ref file - if ProjectRef = viper.GetString("PROJECT_ID"); len(ProjectRef) > 0 { - fmt.Fprintf(debuglogger, "Loading project ref from env var: %s\n", ProjectRef) - return utils.AssertProjectRefIsValid(ProjectRef) - } - if config.UsesStackState() { - return errors.New(utils.ErrNotLinked) - } - // Load from local file last - projectRefBytes, err := afero.ReadFile(fsys, utils.ProjectRefPath) - fmt.Fprintf(debuglogger, "Loading project ref from file: %s\n", utils.ProjectRefPath) - if errors.Is(err, os.ErrNotExist) { - return errors.New(utils.ErrNotLinked) - } else if err != nil { - return errors.Errorf("failed to load project ref: %w", err) - } - ProjectRef = string(bytes.TrimSpace(projectRefBytes)) - return utils.AssertProjectRefIsValid(ProjectRef) -} diff --git a/apps/cli-go/internal/utils/flags/project_ref_test.go b/apps/cli-go/internal/utils/flags/project_ref_test.go deleted file mode 100644 index b497a7b211..0000000000 --- a/apps/cli-go/internal/utils/flags/project_ref_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package flags - -import ( - "context" - "net/http" - "os" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/go-errors/errors" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/config" -) - -func TestProjectRef(t *testing.T) { - t.Run("validates cmd flag", func(t *testing.T) { - ProjectRef = "invalid" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := ParseProjectRef(context.Background(), fsys) - // Check error - assert.Error(t, err, utils.ErrInvalidRef) - }) - - t.Run("loads from linked", func(t *testing.T) { - ProjectRef = "" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup valid project ref - project := apitest.RandomProjectRef() - err := afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) - require.NoError(t, err) - // Run test - err = ParseProjectRef(context.Background(), fsys) - // Check error - assert.NoError(t, err) - }) - - t.Run("ignores the linked file in stack mode", func(t *testing.T) { - t.Setenv(config.StackStateEnv, "1") - ProjectRef = "" - fsys := afero.NewMemMapFs() - decoy := apitest.RandomProjectRef() - require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(decoy), 0644)) - // Run test - err := LoadProjectRef(fsys) - // Check error - assert.ErrorIs(t, err, utils.ErrNotLinked) - assert.Empty(t, ProjectRef) - }) - - t.Run("throws error on read failure", func(t *testing.T) { - ProjectRef = "" - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.ProjectRefPath} - // Run test - err := ParseProjectRef(context.Background(), fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - }) - - t.Run("throws error if all fail", func(t *testing.T) { - ProjectRef = "" - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := ParseProjectRef(context.Background(), fsys) - // Check error - assert.ErrorIs(t, err, utils.ErrNotLinked) - }) -} - -func TestProjectPrompt(t *testing.T) { - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - t.Run("validates prompt input", func(t *testing.T) { - input := tea.WithInput(strings.NewReader("\r")) - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects"). - Reply(http.StatusOK). - JSON([]api.V1ProjectResponseOutput{{ - Id: "test-project", - Name: "My Project", - OrganizationSlug: "test-org", - }}) - // Run test - err := PromptProjectRef(context.Background(), "", input) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on network failure", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects"). - ReplyError(errNetwork) - // Run test - err := PromptProjectRef(context.Background(), "") - // Check error - assert.ErrorIs(t, err, errNetwork) - }) - - t.Run("throws error on service unavailable", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(utils.DefaultApiHost). - Get("/v1/projects"). - Reply(http.StatusServiceUnavailable) - // Run test - err := PromptProjectRef(context.Background(), "") - // Check error - assert.ErrorContains(t, err, "Unexpected error retrieving projects:") - }) -} diff --git a/apps/cli-go/internal/utils/git.go b/apps/cli-go/internal/utils/git.go deleted file mode 100644 index 328e7f94ae..0000000000 --- a/apps/cli-go/internal/utils/git.go +++ /dev/null @@ -1,27 +0,0 @@ -package utils - -import ( - "os" - - "github.com/go-git/go-git/v5" - "github.com/spf13/afero" -) - -func GetGitBranch(fsys afero.Fs) string { - return GetGitBranchOrDefault("main", fsys) -} - -func GetGitBranchOrDefault(def string, fsys afero.Fs) string { - head := os.Getenv("GITHUB_HEAD_REF") - if len(head) > 0 { - return head - } - if root, err := findGitRoot("."); err != nil { - return def - } else if repo, err := git.PlainOpen(root); err == nil { - if ref, err := repo.Head(); err == nil { - return ref.Name().Short() - } - } - return def -} diff --git a/apps/cli-go/internal/utils/identity_transport.go b/apps/cli-go/internal/utils/identity_transport.go deleted file mode 100644 index bcf01d97e7..0000000000 --- a/apps/cli-go/internal/utils/identity_transport.go +++ /dev/null @@ -1,21 +0,0 @@ -package utils - -import "net/http" - -const HeaderGotrueID = "X-Gotrue-Id" - -type identityTransport struct { - http.RoundTripper - onGotrueID *func(string) -} - -func (t *identityTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := t.RoundTripper.RoundTrip(req) - if err != nil { - return resp, err - } - if id := resp.Header.Get(HeaderGotrueID); id != "" && t.onGotrueID != nil && *t.onGotrueID != nil { - (*t.onGotrueID)(id) - } - return resp, err -} diff --git a/apps/cli-go/internal/utils/identity_transport_test.go b/apps/cli-go/internal/utils/identity_transport_test.go deleted file mode 100644 index 314039d0a9..0000000000 --- a/apps/cli-go/internal/utils/identity_transport_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package utils - -import ( - "net/http" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIdentityTransport_CapturesGotrueIdHeader(t *testing.T) { - var captured string - cb := func(id string) { captured = id } - transport := &identityTransport{ - RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 200, - Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}}, - }, nil - }), - onGotrueID: &cb, - } - req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil) - resp, err := transport.RoundTrip(req) - assert.NoError(t, err) - assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, "user-abc-123", captured) -} - -func TestIdentityTransport_IgnoresWhenHeaderMissing(t *testing.T) { - var captured string - cb := func(id string) { captured = id } - transport := &identityTransport{ - RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 200, - Header: http.Header{}, - }, nil - }), - onGotrueID: &cb, - } - req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil) - _, err := transport.RoundTrip(req) - assert.NoError(t, err) - assert.Empty(t, captured) -} - -func TestIdentityTransport_NilCallbackDoesNotPanic(t *testing.T) { - transport := &identityTransport{ - RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 200, - Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}}, - }, nil - }), - onGotrueID: nil, - } - req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil) - resp, err := transport.RoundTrip(req) - assert.NoError(t, err) - assert.Equal(t, 200, resp.StatusCode) -} - -func TestIdentityTransport_NilFuncBehindPointerDoesNotPanic(t *testing.T) { - var cb func(string) // nil func - transport := &identityTransport{ - RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: 200, - Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}}, - }, nil - }), - onGotrueID: &cb, - } - req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil) - resp, err := transport.RoundTrip(req) - assert.NoError(t, err) - assert.Equal(t, 200, resp.StatusCode) -} - -func TestIdentityTransport_InnerTransportError(t *testing.T) { - var captured string - cb := func(id string) { captured = id } - transport := &identityTransport{ - RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, assert.AnError - }), - onGotrueID: &cb, - } - req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil) - resp, err := transport.RoundTrip(req) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Empty(t, captured) -} - -// roundTripFunc is a test helper to create inline RoundTrippers. -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} diff --git a/apps/cli-go/internal/utils/logger.go b/apps/cli-go/internal/utils/logger.go deleted file mode 100644 index 0cde1df38d..0000000000 --- a/apps/cli-go/internal/utils/logger.go +++ /dev/null @@ -1,15 +0,0 @@ -package utils - -import ( - "io" - "os" - - "github.com/spf13/viper" -) - -func GetDebugLogger() io.Writer { - if viper.GetBool("DEBUG") { - return os.Stderr - } - return io.Discard -} diff --git a/apps/cli-go/internal/utils/misc.go b/apps/cli-go/internal/utils/misc.go deleted file mode 100644 index 186573146b..0000000000 --- a/apps/cli-go/internal/utils/misc.go +++ /dev/null @@ -1,318 +0,0 @@ -package utils - -import ( - "context" - _ "embed" - "fmt" - "net" - "os" - "path/filepath" - "regexp" - "time" - - "github.com/containerd/errdefs" - "github.com/docker/docker/client" - "github.com/go-errors/errors" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/format/gitignore" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/utils/agent" - "github.com/supabase/cli/pkg/migration" - "golang.org/x/term" -) - -// Assigned using `-ldflags` https://stackoverflow.com/q/11354518 -var ( - Version string - SentryDsn string - PostHogAPIKey string - PostHogEndpoint string -) - -func ShortContainerImageName(imageName string) string { - matches := ImageNamePattern.FindStringSubmatch(imageName) - if len(matches) < 2 { - return imageName - } - return matches[1] -} - -const SuggestDebugFlag = "Try rerunning the command with --debug to troubleshoot the error." - -const claudeCodeHint = `` - -func SuggestClaudePlugin() string { - if !agent.IsClaudeCode() { - return "" - } - // Suppress the hint when stdout is non-interactive (redirected to a file - // or piped). Without this guard, captured output could be contaminated by - // the trailer if anything ever leaks the tag onto stdout, and the - // install-prompt UX is only meaningful in an interactive shell anyway. - if !term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // G115: stdout fd is a small int on supported platforms - return "" - } - return claudeCodeHint -} - -var ( - CmdSuggestion string - CurrentDirAbs string - - // pg_dumpall --globals-only --no-role-passwords --dbname $DB_URL \ - // | sed '/^CREATE ROLE postgres;/d' \ - // | sed '/^ALTER ROLE postgres WITH /d' \ - // | sed "/^ALTER ROLE .* WITH .* LOGIN /s/;$/ PASSWORD 'postgres';/" - //go:embed templates/globals.sql - GlobalsSql string - - ProjectRefPattern = regexp.MustCompile(`^[a-z]{20}$`) - UUIDPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) - ProjectHostPattern = regexp.MustCompile(`^(db\.)([a-z]{20})\.supabase\.(co|red)$`) - BranchNamePattern = regexp.MustCompile(`[[:word:]-]+`) - FuncSlugPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) - ImageNamePattern = regexp.MustCompile(`\/(.*):`) - - // These schemas are ignored from db diff and db dump - PgSchemas = migration.InternalSchemas[:2] - InternalSchemas = migration.InternalSchemas - - SupabaseDirPath = "supabase" - ConfigPath = filepath.Join(SupabaseDirPath, "config.toml") - GitIgnorePath = filepath.Join(SupabaseDirPath, ".gitignore") - TempDir = filepath.Join(SupabaseDirPath, ".temp") - ImportMapsDir = filepath.Join(TempDir, "import_maps") - ProjectRefPath = filepath.Join(TempDir, "project-ref") - PoolerUrlPath = filepath.Join(TempDir, "pooler-url") - PostgresVersionPath = filepath.Join(TempDir, "postgres-version") - GotrueVersionPath = filepath.Join(TempDir, "gotrue-version") - RestVersionPath = filepath.Join(TempDir, "rest-version") - StorageVersionPath = filepath.Join(TempDir, "storage-version") - StorageMigrationPath = filepath.Join(TempDir, "storage-migration") - StudioVersionPath = filepath.Join(TempDir, "studio-version") - PgmetaVersionPath = filepath.Join(TempDir, "pgmeta-version") - PoolerVersionPath = filepath.Join(TempDir, "pooler-version") - RealtimeVersionPath = filepath.Join(TempDir, "realtime-version") - CliVersionPath = filepath.Join(TempDir, "cli-latest") - CurrBranchPath = filepath.Join(SupabaseDirPath, ".branches", "_current_branch") - // DeclarativeDir is the canonical location for pg-delta declarative schema - // files generated or synced by `supabase db schema declarative` commands. - DeclarativeDir = filepath.Join(SupabaseDirPath, "schemas") - ClusterDir = filepath.Join(SupabaseDirPath, "cluster") - SchemasDir = filepath.Join(SupabaseDirPath, "schemas") - MigrationsDir = filepath.Join(SupabaseDirPath, "migrations") - FunctionsDir = filepath.Join(SupabaseDirPath, "functions") - SnippetsDir = filepath.Join(SupabaseDirPath, "snippets") - FallbackImportMapPath = filepath.Join(FunctionsDir, "import_map.json") - FallbackEnvFilePath = filepath.Join(FunctionsDir, ".env") - DbTestsDir = filepath.Join(SupabaseDirPath, "tests") - CustomRolesPath = filepath.Join(SupabaseDirPath, "roles.sql") - - ErrNotLinked = errors.Errorf("Cannot find project ref. Have you run %s?", Aqua("supabase link")) - ErrInvalidRef = errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.") - ErrInvalidSlug = errors.New("Invalid Function name. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (^[A-Za-z][A-Za-z0-9_-]*$)") - ErrNotRunning = errors.Errorf("%s is not running.", Aqua("supabase start")) -) - -func GetDeclarativeDir() string { - if Config.Experimental.PgDelta != nil && len(Config.Experimental.PgDelta.DeclarativeSchemaPath) > 0 { - return Config.Experimental.PgDelta.DeclarativeSchemaPath - } - return DeclarativeDir -} - -func IsPgDeltaEnabled() bool { - return Config.Experimental.PgDelta != nil && Config.Experimental.PgDelta.Enabled -} - -func GetCurrentTimestamp() string { - // Magic number: https://stackoverflow.com/q/45160822. - return GetVersionTimestamp(time.Now()) -} - -// GetVersionTimestamp formats t as a migration version (UTC `YYYYMMDDHHMMSS`). -// Callers that write several ordered migration files in one pass add real time -// offsets to a shared base rather than incrementing the formatted string. -func GetVersionTimestamp(t time.Time) string { - return t.UTC().Format(layoutVersion) -} - -func GetCurrentBranchFS(fsys afero.Fs) (string, error) { - branch, err := afero.ReadFile(fsys, CurrBranchPath) - if err != nil { - return "", errors.Errorf("failed to load current branch: %w", err) - } - - return string(branch), nil -} - -func AssertSupabaseDbIsRunning() error { - return AssertServiceIsRunning(context.Background(), DbId) -} - -func AssertServiceIsRunning(ctx context.Context, containerId string) error { - if _, err := Docker.ContainerInspect(ctx, containerId); err != nil { - if errdefs.IsNotFound(err) { - return errors.New(ErrNotRunning) - } - if client.IsErrConnectionFailed(err) { - CmdSuggestion = suggestDockerInstall - } - return errors.Errorf("failed to inspect service: %w", err) - } - return nil -} - -func IsGitRepo() bool { - _, err := findGitRoot(".") - return err == nil -} - -func IsGitIgnored(fp ...string) (bool, error) { - root, err := findGitRoot(".") - if err != nil { - return false, err - } - repo, err := git.PlainOpen(root) - if err != nil { - return false, err - } - wt, err := repo.Worktree() - if err != nil { - return false, err - } - ps, err := gitignore.ReadPatterns(wt.Filesystem, nil) - if err != nil { - return false, err - } - m := gitignore.NewMatcher(ps) - return m.Match(fp, false), nil -} - -func findGitRoot(path string) (string, error) { - cwd, err := filepath.Abs(path) - if err != nil { - return "", err - } - for { - if _, err := os.Stat(filepath.Join(cwd, git.GitDirName)); err == nil { - return cwd, nil - } else if !errors.Is(err, os.ErrNotExist) { - return "", err - } - if parent := filepath.Dir(cwd); parent != cwd { - cwd = parent - } else { - return "", git.ErrRepositoryNotExists - } - } -} - -// If the `os.Getwd()` is within a supabase project, this will return -// the root of the given project as the current working directory. -// Otherwise, the `os.Getwd()` is kept as is. -func getProjectRoot(absPath string, fsys afero.Fs) string { - for cwd := absPath; ; cwd = filepath.Dir(cwd) { - path := filepath.Join(cwd, ConfigPath) - // Treat all errors as file not exists - if isSupaProj, err := afero.Exists(fsys, path); isSupaProj { - return cwd - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - logger := GetDebugLogger() - fmt.Fprintln(logger, err) - } - if isRootDirectory(cwd) { - break - } - } - return absPath -} - -func isRootDirectory(cleanPath string) bool { - // A cleaned path only ends with separator if it is root - return os.IsPathSeparator(cleanPath[len(cleanPath)-1]) -} - -func ChangeWorkDir(fsys afero.Fs) error { - // Track the original workdir before changing to project root - if !filepath.IsAbs(CurrentDirAbs) { - var err error - if CurrentDirAbs, err = os.Getwd(); err != nil { - return errors.Errorf("failed to get current directory: %w", err) - } - } - workdir := viper.GetString("WORKDIR") - if len(workdir) == 0 { - workdir = getProjectRoot(CurrentDirAbs, fsys) - } - if err := os.Chdir(workdir); err != nil { - return errors.Errorf("failed to change workdir: %w", err) - } - if cwd, err := os.Getwd(); err == nil && cwd != CurrentDirAbs { - fmt.Fprintln(os.Stderr, "Using workdir", Bold(workdir)) - } - return nil -} - -func IsBranchNameReserved(branch string) bool { - switch branch { - case "_current_branch", "main", "postgres", "template0", "template1": - return true - default: - return false - } -} - -func MkdirIfNotExist(path string) error { - return MkdirIfNotExistFS(afero.NewOsFs(), path) -} - -func MkdirIfNotExistFS(fsys afero.Fs, path string) error { - if err := fsys.MkdirAll(path, 0755); err != nil && !errors.Is(err, os.ErrExist) { - return errors.Errorf("failed to mkdir: %w", err) - } - - return nil -} - -func WriteFile(path string, contents []byte, fsys afero.Fs) error { - if err := MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return err - } - if err := afero.WriteFile(fsys, path, contents, 0644); err != nil { - return errors.Errorf("failed to write file: %w", err) - } - return nil -} - -func AssertProjectRefIsValid(projectRef string) error { - if !ProjectRefPattern.MatchString(projectRef) { - return errors.New(ErrInvalidRef) - } - return nil -} - -func ValidateFunctionSlug(slug string) error { - if !FuncSlugPattern.MatchString(slug) { - return errors.New(ErrInvalidSlug) - } - - return nil -} - -func GetHostname() string { - // Overrides the host for local service connections. Useful when - // running inside a dev container when the Docker host is not - // 127.0.0.1 (container's own loopback). - if h := os.Getenv("SUPABASE_SERVICES_HOSTNAME"); h != "" { - return h - } - host := Docker.DaemonHost() - if parsed, err := client.ParseHostURL(host); err == nil && parsed.Scheme == "tcp" { - if host, _, err := net.SplitHostPort(parsed.Host); err == nil { - return host - } - } - return "127.0.0.1" -} diff --git a/apps/cli-go/internal/utils/misc_test.go b/apps/cli-go/internal/utils/misc_test.go deleted file mode 100644 index e8e075201b..0000000000 --- a/apps/cli-go/internal/utils/misc_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package utils - -import ( - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/config" -) - -type MockFs struct { - afero.MemMapFs - DenyPath string -} - -func (m *MockFs) Stat(name string) (fs.FileInfo, error) { - if strings.HasPrefix(name, m.DenyPath) { - return nil, fs.ErrPermission - } - return m.MemMapFs.Stat(name) -} - -func TestProjectRoot(t *testing.T) { - root := string(filepath.Separator) - - t.Run("stops at root dir", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - _, err := fsys.Create(filepath.Join(root, ConfigPath)) - require.NoError(t, err) - // Run test - cwd := filepath.Join(root, "home", "user", "project") - path := getProjectRoot(cwd, fsys) - // Check error - assert.Equal(t, root, path) - }) - - t.Run("stops at closest parent", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - _, err := fsys.Create(filepath.Join(root, "supabase", ConfigPath)) - require.NoError(t, err) - // Run test - cwd := filepath.Join(root, "supabase", "supabase", "functions") - path := getProjectRoot(cwd, fsys) - // Check error - assert.Equal(t, filepath.Join(root, "supabase"), path) - }) - - t.Run("ignores error on config not found", func(t *testing.T) { - cwd, err := os.Getwd() - require.NoError(t, err) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - path := getProjectRoot(cwd, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, cwd, path) - }) - - t.Run("ignores error if path is not directory", func(t *testing.T) { - cwd, err := os.Getwd() - require.NoError(t, err) - // Setup in-memory fs - fsys := &MockFs{DenyPath: filepath.Join(cwd, "supabase")} - // Run test - path := getProjectRoot(cwd, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, cwd, path) - }) -} - -func TestShortContainerImageName(t *testing.T) { - t.Run("extracts short name from image", func(t *testing.T) { - input := "registry.supabase.com/postgres:15.1.0.99" - expected := "postgres" - - result := ShortContainerImageName(input) - - assert.Equal(t, expected, result) - }) -} - -func TestIsBranchNameReserved(t *testing.T) { - t.Run("identifies reserved names", func(t *testing.T) { - reserved := []string{"_current_branch", "main"} - for _, name := range reserved { - assert.True(t, IsBranchNameReserved(name), "Expected %s to be reserved", name) - } - }) - - t.Run("allows custom names", func(t *testing.T) { - allowed := []string{"my-feature", "test-branch-123"} - for _, name := range allowed { - assert.False(t, IsBranchNameReserved(name), "Expected %s to be allowed", name) - } - }) -} - -func TestValidateFunctionSlug(t *testing.T) { - t.Run("validates correct slugs", func(t *testing.T) { - valid := []string{ - "my-function", - "MyFunction", - "function_1", - "a123", - } - for _, slug := range valid { - err := ValidateFunctionSlug(slug) - assert.NoError(t, err, "Expected %s to be valid", slug) - } - }) - - t.Run("rejects invalid slugs", func(t *testing.T) { - invalid := []string{ - "1function", - "my function", - "function!", - "", - "-function", - "_function", - } - for _, slug := range invalid { - err := ValidateFunctionSlug(slug) - assert.ErrorIs(t, err, ErrInvalidSlug, "Expected %s to be invalid", slug) - } - }) -} - -func TestAssertProjectRefIsValid(t *testing.T) { - t.Run("validates correct refs", func(t *testing.T) { - validRef := "abcdefghijklmnopqrst" - err := AssertProjectRefIsValid(validRef) - assert.NoError(t, err) - }) - - t.Run("rejects invalid refs", func(t *testing.T) { - invalid := []string{ - "tooshort", - "toolongabcdefghijklmnopqrst", - "UPPERCASE", - "special-chars", - "123", - "", - } - for _, ref := range invalid { - err := AssertProjectRefIsValid(ref) - assert.ErrorIs(t, err, ErrInvalidRef, "Expected %s to be invalid", ref) - } - }) -} - -func TestGetHostname(t *testing.T) { - t.Run("returns SUPABASE_SERVICES_HOSTNAME when set", func(t *testing.T) { - t.Setenv("SUPABASE_SERVICES_HOSTNAME", "host.docker.internal") - assert.Equal(t, "host.docker.internal", GetHostname()) - }) - - t.Run("returns 127.0.0.1 when SUPABASE_SERVICES_HOSTNAME is not set", func(t *testing.T) { - t.Setenv("SUPABASE_SERVICES_HOSTNAME", "") - assert.Equal(t, "127.0.0.1", GetHostname()) - }) -} - -func TestWriteFile(t *testing.T) { - t.Run("writes file with directories", func(t *testing.T) { - fsys := afero.NewMemMapFs() - path := filepath.Join("deep", "nested", "dir", "file.txt") - content := []byte("test content") - - err := WriteFile(path, content, fsys) - - assert.NoError(t, err) - - written, err := afero.ReadFile(fsys, path) - assert.NoError(t, err) - assert.Equal(t, content, written) - }) - - t.Run("overwrites existing file", func(t *testing.T) { - fsys := afero.NewMemMapFs() - path := "test.txt" - original := []byte("original") - updated := []byte("updated") - require.NoError(t, afero.WriteFile(fsys, path, original, 0644)) - - err := WriteFile(path, updated, fsys) - - assert.NoError(t, err) - written, err := afero.ReadFile(fsys, path) - assert.NoError(t, err) - assert.Equal(t, updated, written) - }) -} - -func TestGetDeclarativeDir(t *testing.T) { - t.Run("uses configured pgdelta path", func(t *testing.T) { - Config.Experimental.PgDelta = &config.PgDeltaConfig{ - DeclarativeSchemaPath: filepath.Join(SupabaseDirPath, "db", "decl"), - } - - assert.Equal(t, filepath.Join(SupabaseDirPath, "db", "decl"), GetDeclarativeDir()) - }) - - t.Run("falls back to default declarative dir", func(t *testing.T) { - Config.Experimental.PgDelta = nil - - assert.Equal(t, DeclarativeDir, GetDeclarativeDir()) - }) -} diff --git a/apps/cli-go/internal/utils/output.go b/apps/cli-go/internal/utils/output.go deleted file mode 100644 index df879fb427..0000000000 --- a/apps/cli-go/internal/utils/output.go +++ /dev/null @@ -1,123 +0,0 @@ -package utils - -import ( - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/BurntSushi/toml" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glamour/styles" - "github.com/go-errors/errors" - "github.com/go-viper/mapstructure/v2" - "github.com/joho/godotenv" - "github.com/spf13/viper" - "gopkg.in/yaml.v3" -) - -const ( - OutputEnv = "env" - OutputJson = "json" - OutputPretty = "pretty" - OutputToml = "toml" - OutputYaml = "yaml" - - // OutputMetadata is used with certain SSO commands only. - OutputMetadata = "metadata" -) - -var OutputFormat = EnumFlag{ - Allowed: []string{ - OutputEnv, - OutputPretty, - OutputJson, - OutputToml, - OutputYaml, - }, - Value: OutputPretty, -} - -var ErrEnvNotSupported = errors.New("--output env flag is not supported") - -func EncodeOutput(format string, w io.Writer, value any) error { - switch format { - case OutputEnv: - mapvalue, err := ToEnvMap(value) - if err != nil { - return err - } - - out, err := godotenv.Marshal(mapvalue) - if err != nil { - return errors.Errorf("failed to marshal env map: %w", err) - } - - if _, err := fmt.Fprintln(w, out); err != nil { - return errors.Errorf("failed to write encoded output: %w", err) - } - - case OutputJson: - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - - if err := enc.Encode(value); err != nil { - return errors.Errorf("failed to output json: %w", err) - } - - case OutputYaml: - enc := yaml.NewEncoder(w) - if err := enc.Encode(value); err != nil { - return errors.Errorf("failed to output yaml: %w", err) - } - - case OutputToml: - enc := toml.NewEncoder(w) - if err := enc.Encode(value); err != nil { - return errors.Errorf("failed to output toml: %w", err) - } - - default: - return errors.Errorf("Unsupported output encoding %q", format) - } - return nil -} - -func ToEnvMap(value any) (map[string]string, error) { - var result map[string]any - if dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ - TagName: "json", - Result: &result, - }); err != nil { - return nil, errors.Errorf("failed to init decoder: %w", err) - } else if err := dec.Decode(value); err != nil { - return nil, errors.Errorf("failed to decode env: %w", err) - } - v := viper.New() - if err := v.MergeConfigMap(result); err != nil { - return nil, errors.Errorf("failed to merge env: %w", err) - } - keys := v.AllKeys() - mapvalue := make(map[string]string, len(keys)) - for _, k := range keys { - name := strings.ToUpper(strings.ReplaceAll(k, ".", "_")) - mapvalue[name] = v.GetString(k) - } - return mapvalue, nil -} - -func RenderTable(markdown string) error { - r, err := glamour.NewTermRenderer( - glamour.WithStandardStyle(styles.AsciiStyle), - glamour.WithWordWrap(-1), - ) - if err != nil { - return errors.Errorf("failed to initialise terminal renderer: %w", err) - } - out, err := r.Render(markdown) - if err != nil { - return errors.Errorf("failed to render markdown: %w", err) - } - fmt.Print(out) - return nil -} diff --git a/apps/cli-go/internal/utils/output_test.go b/apps/cli-go/internal/utils/output_test.go deleted file mode 100644 index a04be2536b..0000000000 --- a/apps/cli-go/internal/utils/output_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package utils - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEncodeOutput(t *testing.T) { - t.Run("encodes flat env", func(t *testing.T) { - input := map[string]string{ - "DATABASE_URL": "postgres://user:pass@host:5432/db", - "API_KEY": "secret-key", - } - var buf bytes.Buffer - err := EncodeOutput(OutputEnv, &buf, input) - assert.NoError(t, err) - expected := "API_KEY=\"secret-key\"\nDATABASE_URL=\"postgres://user:pass@host:5432/db\"\n" - assert.Equal(t, expected, buf.String()) - }) - - t.Run("encodes nested env", func(t *testing.T) { - input := map[string]any{ - "FOO": map[string]any{ - "BAR": 123, - }, - } - var buf bytes.Buffer - err := EncodeOutput(OutputEnv, &buf, input) - assert.NoError(t, err) - assert.Equal(t, "FOO_BAR=123\n", buf.String()) - }) - - t.Run("encodes json format", func(t *testing.T) { - input := map[string]any{ - "name": "test", - "config": map[string]any{ - "port": 5432, - "enabled": true, - }, - } - var buf bytes.Buffer - err := EncodeOutput(OutputJson, &buf, input) - assert.NoError(t, err) - expected := `{ - "config": { - "enabled": true, - "port": 5432 - }, - "name": "test" -} -` - assert.Equal(t, expected, buf.String()) - }) - - t.Run("encodes yaml format", func(t *testing.T) { - input := map[string]any{ - "name": "test", - "config": map[string]any{ - "port": 5432, - "enabled": true, - }, - } - var buf bytes.Buffer - err := EncodeOutput(OutputYaml, &buf, input) - assert.NoError(t, err) - expected := `config: - enabled: true - port: 5432 -name: test -` - assert.Equal(t, expected, buf.String()) - }) - - t.Run("encodes toml format", func(t *testing.T) { - input := map[string]any{ - "name": "test", - "config": map[string]any{ - "port": 5432, - "enabled": true, - }, - } - var buf bytes.Buffer - err := EncodeOutput(OutputToml, &buf, input) - assert.NoError(t, err) - expected := `name = "test" - -[config] - enabled = true - port = 5432 -` - assert.Equal(t, expected, buf.String()) - }) - - t.Run("fails with unsupported format", func(t *testing.T) { - var buf bytes.Buffer - err := EncodeOutput("invalid", &buf, nil) - assert.ErrorContains(t, err, `Unsupported output encoding "invalid"`) - }) - - t.Run("handles complex nested structures", func(t *testing.T) { - input := map[string]any{ - "database": map[string]any{ - "connections": []map[string]any{ - { - "host": "localhost", - "port": 5432, - }, - { - "host": "remote", - "port": 6543, - }, - }, - "settings": map[string]any{ - "max_connections": 100, - "ssl_enabled": true, - }, - }, - } - var buf bytes.Buffer - err := EncodeOutput(OutputJson, &buf, input) - require.NoError(t, err) - expected := `{ - "database": { - "connections": [ - { - "host": "localhost", - "port": 5432 - }, - { - "host": "remote", - "port": 6543 - } - ], - "settings": { - "max_connections": 100, - "ssl_enabled": true - } - } -} -` - assert.Equal(t, expected, buf.String()) - }) -} diff --git a/apps/cli-go/internal/utils/plan_gate.go b/apps/cli-go/internal/utils/plan_gate.go deleted file mode 100644 index c666725557..0000000000 --- a/apps/cli-go/internal/utils/plan_gate.go +++ /dev/null @@ -1,97 +0,0 @@ -package utils - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/supabase/cli/pkg/api" -) - -func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) { - resp, err := GetSupabase().V1GetProjectWithResponse(ctx, projectRef) - if err != nil { - return "", fmt.Errorf("failed to get project: %w", err) - } - if resp.JSON200 == nil { - return "", fmt.Errorf("unexpected get project status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return resp.JSON200.OrganizationSlug, nil -} - -func GetOrgBillingURL(orgSlug string) string { - return fmt.Sprintf("%s/org/%s/billing", GetSupabaseDashboardURL(), orgSlug) -} - -func orgSlugFromBillingURL(billingURL string) string { - const marker = "/org/" - start := strings.Index(billingURL, marker) - if start < 0 { - return "" - } - rest := billingURL[start+len(marker):] - if end := strings.Index(rest, "/"); end >= 0 { - return rest[:end] - } - return rest -} - -func parsePlanGateError(body []byte) (feature, upgradeURL string, ok bool) { - var envelope api.PlanGateErrorBody - if err := json.Unmarshal(body, &envelope); err != nil || envelope.Error == nil { - return "", "", false - } - if envelope.Error.Code != api.EntitlementRequired || envelope.Error.Feature == "" { - return "", "", false - } - if envelope.Error.UpgradeUrl == nil || *envelope.Error.UpgradeUrl == "" { - return "", "", false - } - return envelope.Error.Feature, *envelope.Error.UpgradeUrl, true -} - -func setUpgradeSuggestion(billingURL string) { - CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) -} - -// SuggestUpgradeOnError checks whether a failed API response is a plan gate and -// sets CmdSuggestion with the billing URL when it is. The entitlement_required -// envelope on the response body is authoritative and needs no network calls; -// when absent, a non-empty featureKey falls back to the entitlements lookup. -// An empty featureKey disables the fallback (envelope-only sites). Returns the -// effective feature and org slug for telemetry, and whether a suggestion was -// shown. Only fires on 4xx. -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int, body []byte) (gatedFeature, orgSlug string, isGated bool) { - if statusCode < 400 || statusCode >= 500 { - return "", "", false - } - - if feature, upgradeURL, ok := parsePlanGateError(body); ok { - setUpgradeSuggestion(upgradeURL) - return feature, orgSlugFromBillingURL(upgradeURL), true - } - - if featureKey == "" { - return "", "", false - } - - slug, err := GetOrgSlugFromProjectRef(ctx, projectRef) - if err != nil { - return "", "", false - } - - resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, slug) - if err != nil || resp.JSON200 == nil { - return "", slug, false - } - - for _, e := range resp.JSON200.Entitlements { - if string(e.Feature.Key) == featureKey && !e.HasAccess { - setUpgradeSuggestion(GetOrgBillingURL(slug)) - return featureKey, slug, true - } - } - - return "", slug, false -} diff --git a/apps/cli-go/internal/utils/plan_gate_test.go b/apps/cli-go/internal/utils/plan_gate_test.go deleted file mode 100644 index 5f85d5076f..0000000000 --- a/apps/cli-go/internal/utils/plan_gate_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package utils - -import ( - "context" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/apitest" -) - -var planGateProjectJSON = map[string]interface{}{ - "ref": "test-ref", - "organization_slug": "my-org", - "name": "test", - "region": "us-east-1", - "created_at": "2024-01-01T00:00:00Z", - "status": "ACTIVE_HEALTHY", - "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, -} - -func TestGetOrgSlugFromProjectRef(t *testing.T) { - ref := apitest.RandomProjectRef() - - t.Run("returns org slug on success", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusOK). - JSON(planGateProjectJSON) - slug, err := GetOrgSlugFromProjectRef(context.Background(), ref) - assert.NoError(t, err) - assert.Equal(t, "my-org", slug) - }) - - t.Run("returns error on not found", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusNotFound) - _, err := GetOrgSlugFromProjectRef(context.Background(), ref) - assert.ErrorContains(t, err, "unexpected get project status 404") - }) - - t.Run("returns error on network failure", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - ReplyError(assert.AnError) - _, err := GetOrgSlugFromProjectRef(context.Background(), ref) - assert.ErrorContains(t, err, "failed to get project") - }) -} - -func TestGetOrgBillingURL(t *testing.T) { - url := GetOrgBillingURL("my-org") - assert.Equal(t, GetSupabaseDashboardURL()+"/org/my-org/billing", url) -} - -func entitlementsJSON(featureKey string, hasAccess bool) map[string]interface{} { - return map[string]interface{}{ - "entitlements": []map[string]interface{}{ - { - "feature": map[string]interface{}{"key": featureKey, "type": "numeric"}, - "hasAccess": hasAccess, - "type": "numeric", - "config": map[string]interface{}{"enabled": hasAccess, "value": 0, "unlimited": false, "unit": "count"}, - }, - }, - } -} - -// mockEntitlementsCheck sets up gock mocks for project lookup + entitlements. -func mockEntitlementsCheck(ref string, featureKey string, hasAccess bool) { - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusOK). - JSON(planGateProjectJSON) - gock.New(DefaultApiHost). - Get("/v1/organizations/my-org/entitlements"). - Reply(http.StatusOK). - JSON(entitlementsJSON(featureKey, hasAccess)) -} - -func TestSuggestUpgradeOnError(t *testing.T) { - ref := apitest.RandomProjectRef() - - t.Run("sets suggestion on 402 with gated feature", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "branching_limit", false) - feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) - assert.True(t, got) - assert.Equal(t, "branching_limit", feature) - assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - assert.Contains(t, CmdSuggestion, "does not have access") - }) - - t.Run("sets suggestion on 400 with gated feature", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "vanity_subdomain", false) - feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest, nil) - assert.True(t, got) - assert.Equal(t, "vanity_subdomain", feature) - assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - assert.Contains(t, CmdSuggestion, "does not have access") - }) - - t.Run("sets suggestion on 404 with gated feature", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "auth.saml_2", false) - _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound, nil) - assert.True(t, got) - assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - }) - - t.Run("no suggestion when entitlements lookup fails", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusOK). - JSON(planGateProjectJSON) - gock.New(DefaultApiHost). - Get("/v1/organizations/my-org/entitlements"). - Reply(http.StatusInternalServerError) - _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) - assert.False(t, got) - assert.Equal(t, "my-org", slug) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("no suggestion when project lookup fails", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusNotFound) - _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) - assert.False(t, got) - assert.Empty(t, slug) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("no suggestion when feature has access", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "branching_limit", true) - _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) - assert.False(t, got) - assert.Equal(t, "my-org", slug) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("skips on 503 server error", func(t *testing.T) { - CmdSuggestion = "" - _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable, nil) - assert.False(t, got) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("skips on 200", func(t *testing.T) { - CmdSuggestion = "" - _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK, nil) - assert.False(t, got) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("skips on 201", func(t *testing.T) { - CmdSuggestion = "" - _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated, nil) - assert.False(t, got) - assert.Empty(t, CmdSuggestion) - }) -} - -func TestSuggestUpgradeEnvelope(t *testing.T) { - envelope := `{"message":"x","error":{"code":"entitlement_required","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` - - t.Run("envelope sets suggestion with zero API calls", func(t *testing.T) { - t.Cleanup(func() { CmdSuggestion = "" }) - feature, slug, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(envelope)) - assert.True(t, gated) - assert.Equal(t, "custom_domain", feature) - assert.Equal(t, "acme", slug) - assert.Contains(t, CmdSuggestion, "org/acme/billing") - assert.Contains(t, CmdSuggestion, "does not have access") - }) - - t.Run("envelope feature wins over call-site featureKey", func(t *testing.T) { - t.Cleanup(func() { CmdSuggestion = "" }) - body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_persistent","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` - feature, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "branching_limit", http.StatusPaymentRequired, []byte(body)) - assert.True(t, gated) - assert.Equal(t, "branching_persistent", feature) - }) - - t.Run("envelope without upgrade_url falls back to entitlements", func(t *testing.T) { - ref := apitest.RandomProjectRef() - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "branching_limit", false) - body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_limit"}}` - feature, slug, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(body)) - assert.True(t, gated) - assert.Equal(t, "branching_limit", feature) - assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - }) - - t.Run("malformed body falls back to entitlements", func(t *testing.T) { - ref := apitest.RandomProjectRef() - t.Cleanup(apitest.MockPlatformAPI(t)) - t.Cleanup(func() { CmdSuggestion = "" }) - mockEntitlementsCheck(ref, "branching_limit", false) - _, _, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(`not json`)) - assert.True(t, gated) - }) - - t.Run("no envelope and empty featureKey is a no-op with zero API calls", func(t *testing.T) { - t.Cleanup(func() { CmdSuggestion = "" }) - _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusNotFound, []byte(`{"message":"not found"}`)) - assert.False(t, gated) - assert.Empty(t, CmdSuggestion) - }) - - t.Run("non-gate error code is ignored", func(t *testing.T) { - t.Cleanup(func() { CmdSuggestion = "" }) - body := `{"message":"x","error":{"code":"validation_failed","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` - _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(body)) - assert.False(t, gated) - assert.Empty(t, CmdSuggestion) - }) -} - -func TestOrgSlugFromBillingURL(t *testing.T) { - cases := map[string]string{ - "https://supabase.com/dashboard/org/acme/billing": "acme", - "https://supabase.com/dashboard/org/acme": "acme", - "https://example.com/nope": "", - } - for in, want := range cases { - assert.Equal(t, want, orgSlugFromBillingURL(in), in) - } -} diff --git a/apps/cli-go/internal/utils/profile.go b/apps/cli-go/internal/utils/profile.go deleted file mode 100644 index 4b24870271..0000000000 --- a/apps/cli-go/internal/utils/profile.go +++ /dev/null @@ -1,160 +0,0 @@ -package utils - -import ( - "context" - "fmt" - "path/filepath" - "sort" - "strings" - - "github.com/go-errors/errors" - "github.com/go-playground/validator/v10" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/api" -) - -type Profile struct { - Name string `mapstructure:"name" validate:"required"` - APIURL string `mapstructure:"api_url" validate:"required,http_url"` - DashboardURL string `mapstructure:"dashboard_url" validate:"required,http_url"` - DocsURL string `mapstructure:"docs_url" validate:"omitempty,http_url"` - ProjectHost string `mapstructure:"project_host" validate:"required,hostname_rfc1123"` - PoolerHost string `mapstructure:"pooler_host" validate:"omitempty,hostname_rfc1123"` - AuthClientID string `mapstructure:"client_id" validate:"omitempty,uuid4"` - StudioImage string `mapstructure:"studio_image"` - ProjectRegions []api.V1CreateProjectBodyRegion `mapstructure:"regions"` -} - -var allProfiles = []Profile{{ - Name: "supabase", - APIURL: "https://api.supabase.com", - DashboardURL: "https://supabase.com/dashboard", - DocsURL: "https://supabase.com/docs", - ProjectHost: "supabase.co", - PoolerHost: "supabase.com", - ProjectRegions: []api.V1CreateProjectBodyRegion{ - api.V1CreateProjectBodyRegionApEast1, - api.V1CreateProjectBodyRegionApNortheast1, - api.V1CreateProjectBodyRegionApNortheast2, - api.V1CreateProjectBodyRegionApSouth1, - api.V1CreateProjectBodyRegionApSoutheast1, - api.V1CreateProjectBodyRegionApSoutheast2, - api.V1CreateProjectBodyRegionCaCentral1, - api.V1CreateProjectBodyRegionEuCentral1, - api.V1CreateProjectBodyRegionEuCentral2, - api.V1CreateProjectBodyRegionEuNorth1, - api.V1CreateProjectBodyRegionEuWest1, - api.V1CreateProjectBodyRegionEuWest2, - api.V1CreateProjectBodyRegionEuWest3, - api.V1CreateProjectBodyRegionSaEast1, - api.V1CreateProjectBodyRegionUsEast1, - api.V1CreateProjectBodyRegionUsEast2, - api.V1CreateProjectBodyRegionUsWest1, - api.V1CreateProjectBodyRegionUsWest2, - }, -}, { - Name: "supabase-staging", - APIURL: "https://api.supabase.green", - DashboardURL: "https://supabase.green/dashboard", - DocsURL: "https://supabase.com/docs", - ProjectHost: "supabase.red", - PoolerHost: "supabase.green", - ProjectRegions: []api.V1CreateProjectBodyRegion{ - api.V1CreateProjectBodyRegionApSoutheast1, - api.V1CreateProjectBodyRegionUsEast1, - api.V1CreateProjectBodyRegionEuCentral1, - }, -}, { - Name: "supabase-local", - APIURL: "http://localhost:8080", - DashboardURL: "http://localhost:8082", - DocsURL: "https://supabase.com/docs", - ProjectHost: "supabase.red", - ProjectRegions: []api.V1CreateProjectBodyRegion{ - api.V1CreateProjectBodyRegionApSoutheast1, - api.V1CreateProjectBodyRegionUsEast1, - api.V1CreateProjectBodyRegionEuCentral1, - }, -}, { - Name: "snap", - APIURL: "https://cloudapi.snap.com", - DashboardURL: "https://cloud.snap.com/dashboard", - DocsURL: "https://cloud.snap.com/docs", - ProjectHost: "snapcloud.dev", - PoolerHost: "snapcloud.co", - AuthClientID: "f7573b20-df47-48f1-b606-e8db4ec16252", - ProjectRegions: []api.V1CreateProjectBodyRegion{ - api.V1CreateProjectBodyRegionUsEast1, - }, -}} - -var CurrentProfile Profile - -func LoadProfile(ctx context.Context, fsys afero.Fs) error { - prof := getProfileName(fsys) - for _, p := range allProfiles { - if strings.EqualFold(p.Name, prof) { - CurrentProfile = p - return nil - } - } - // Instantiate to avoid leaking profile into global viper state - v := viper.New() - v.SetFs(fsys) - v.SetConfigFile(prof) - if err := v.ReadInConfig(); err != nil { - return errors.Errorf("failed to read profile: %w", err) - } - // Load profile into viper, rejecting keys not defined by config - if err := v.UnmarshalExact(&CurrentProfile); err != nil { - return errors.Errorf("failed to parse profile: %w", err) - } - validate := validator.New(validator.WithRequiredStructEnabled()) - if err := validate.StructCtx(ctx, &CurrentProfile); err != nil { - return errors.Errorf("invalid profile: %w", err) - } - return nil -} - -func getProfileName(fsys afero.Fs) string { - debuglogger := GetDebugLogger() - if prof := viper.GetString("PROFILE"); viper.IsSet("PROFILE") { - fmt.Fprintln(debuglogger, "Loading profile from flag:", prof) - return prof - } else if profilePath, err := getProfilePath(); err != nil { - fmt.Fprintln(debuglogger, err) - return prof - } else if content, err := afero.ReadFile(fsys, profilePath); err == nil { - fmt.Fprintln(debuglogger, "Loading profile from file:", profilePath) - return string(content) - } else { - fmt.Fprintln(debuglogger, err) - return prof - } -} - -func getProfilePath() (string, error) { - home, err := SupabaseHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, "profile"), nil -} - -func SaveProfileName(prof string, fsys afero.Fs) error { - profilePath, err := getProfilePath() - if err != nil { - return err - } - return WriteFile(profilePath, []byte(prof), fsys) -} - -func AwsRegions() []string { - result := make([]string, len(allProfiles[0].ProjectRegions)) - for i, region := range allProfiles[0].ProjectRegions { - result[i] = string(region) - } - sort.Strings(result) - return result -} diff --git a/apps/cli-go/internal/utils/profile_test.go b/apps/cli-go/internal/utils/profile_test.go deleted file mode 100644 index 4c5925e2d4..0000000000 --- a/apps/cli-go/internal/utils/profile_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package utils - -import ( - "context" - "embed" - "os" - "path/filepath" - "testing" - - "github.com/go-playground/validator/v10" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" -) - -//go:embed testdata/*.json -var testdata embed.FS - -func TestLoadProfile(t *testing.T) { - validate := validator.New(validator.WithRequiredStructEnabled()) - for _, p := range allProfiles { - t.Run("loads profile "+p.Name, func(t *testing.T) { - viper.Set("PROFILE", p.Name) - t.Cleanup(viper.Reset) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := LoadProfile(context.Background(), fsys) - // Check error - assert.NoError(t, err) - assert.NoError(t, validate.Struct(&CurrentProfile)) - }) - } - - t.Run("loads from json", func(t *testing.T) { - viper.Set("PROFILE", "testdata/profile.json") - t.Cleanup(viper.Reset) - // Setup in-memory fs - fsys := afero.FromIOFS{FS: testdata} - // Run test - err := LoadProfile(context.Background(), fsys) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on invalid profile", func(t *testing.T) { - viper.Set("PROFILE", "testdata/invalid.json") - t.Cleanup(viper.Reset) - // Setup in-memory fs - fsys := afero.FromIOFS{FS: testdata} - // Run test - err := LoadProfile(context.Background(), fsys) - // Check error - assert.ErrorContains(t, err, "Field validation for 'APIURL' failed on the 'http_url' tag") - assert.ErrorContains(t, err, "Field validation for 'ProjectHost' failed on the 'hostname_rfc1123' tag") - }) - - t.Run("throws error on malformed profile", func(t *testing.T) { - viper.Set("PROFILE", "testdata/malformed.json") - t.Cleanup(viper.Reset) - // Setup in-memory fs - fsys := afero.FromIOFS{FS: testdata} - // Run test - err := LoadProfile(context.Background(), fsys) - // Check error - assert.ErrorContains(t, err, "invalid keys: test_url") - }) - - t.Run("throws error on missing profile", func(t *testing.T) { - viper.Set("PROFILE", "testdata/missing.json") - t.Cleanup(viper.Reset) - // Setup in-memory fs - fsys := afero.FromIOFS{FS: testdata} - // Run test - err := LoadProfile(context.Background(), fsys) - // Check error - assert.ErrorIs(t, err, os.ErrNotExist) - }) -} - -func TestSaveProfileName(t *testing.T) { - t.Run("saves to SUPABASE_HOME when configured", func(t *testing.T) { - t.Setenv("HOME", "/home/test") - t.Setenv("SUPABASE_HOME", "/custom/supabase") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := SaveProfileName("supabase-staging", fsys) - // Check error - assert.NoError(t, err) - contents, err := afero.ReadFile(fsys, filepath.Join("/custom/supabase", "profile")) - assert.NoError(t, err) - assert.Equal(t, "supabase-staging", string(contents)) - exists, err := afero.Exists(fsys, filepath.Join("/home/test", ".supabase", "profile")) - assert.NoError(t, err) - assert.False(t, exists) - }) -} diff --git a/apps/cli-go/internal/utils/prompt.go b/apps/cli-go/internal/utils/prompt.go deleted file mode 100644 index ae2dedf2ae..0000000000 --- a/apps/cli-go/internal/utils/prompt.go +++ /dev/null @@ -1,144 +0,0 @@ -package utils - -import ( - "context" - "fmt" - "io" - "os" - "strings" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/go-errors/errors" -) - -var ( - titleStyle = lipgloss.NewStyle().MarginLeft(2) - itemStyle = lipgloss.NewStyle().PaddingLeft(4) - selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) - paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) - helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) -) - -// PromptItem is exposed as prompt input, empty summary + details will be excluded. -type PromptItem struct { - Summary string - Details string - Index int -} - -func (i PromptItem) Title() string { return i.Summary } -func (i PromptItem) Description() string { return i.Details } -func (i PromptItem) FilterValue() string { return i.Summary + " " + i.Details } - -// Item delegate is used to finetune the list item renderer. -type itemDelegate struct{} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(PromptItem) - if !ok { - return - } - - str := fmt.Sprintf("%d. %s", index+1, i.Summary) - if i.Details != "" { - str += fmt.Sprintf(" [%s]", i.Details) - } - - fn := itemStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - items := append([]string{"> "}, s...) - return selectedItemStyle.Render(items...) - } - } - - fmt.Fprint(w, fn(str)) -} - -// Model is used to store state of user choices. -type model struct { - cancel context.CancelFunc - list list.Model - choice PromptItem -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.list.SetWidth(msg.Width) - return m, nil - - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC: - m.cancel() - return m, tea.Quit - - case tea.KeyEnter: - if choice, ok := m.list.SelectedItem().(PromptItem); ok { - m.choice = choice - } - return m, tea.Quit - } - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m model) View() string { - if m.choice.Summary != "" { - return "" - } - return "\n" + m.list.View() -} - -// Prompt user to choose from a list of items, returns the chosen index. -func PromptChoice(ctx context.Context, title string, items []PromptItem, opts ...tea.ProgramOption) (PromptItem, error) { - // Create list items - var listItems []list.Item - for _, v := range items { - if strings.TrimSpace(v.FilterValue()) == "" { - continue - } - listItems = append(listItems, v) - } - // Create list model - height := min(len(listItems)*4, 14) - l := list.New(listItems, itemDelegate{}, 0, height) - l.Title = title - l.SetShowStatusBar(false) - l.Styles.Title = titleStyle - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle - // Create our model - ctx, cancel := context.WithCancel(ctx) - initial := model{cancel: cancel, list: l} - // Interactive prompts should always be written to stderr - opts = append(opts, tea.WithOutput(os.Stderr)) - prog := tea.NewProgram(initial, opts...) - state, err := prog.Run() - if err != nil { - return initial.choice, errors.Errorf("failed to prompt choice: %w", err) - } - if ctx.Err() != nil { - return initial.choice, ctx.Err() - } - if m, ok := state.(model); ok { - if m.choice == initial.choice { - return initial.choice, errors.New("user aborted") - } - return m.choice, nil - } - return initial.choice, err -} diff --git a/apps/cli-go/internal/utils/release.go b/apps/cli-go/internal/utils/release.go deleted file mode 100644 index c68430c6aa..0000000000 --- a/apps/cli-go/internal/utils/release.go +++ /dev/null @@ -1,48 +0,0 @@ -package utils - -import ( - "context" - "net/http" - "os" - "sync" - - "github.com/go-errors/errors" - "github.com/google/go-github/v62/github" - "golang.org/x/oauth2" -) - -var ( - githubClient *github.Client - githubOnce sync.Once -) - -func GetGitHubClient(ctx context.Context) *github.Client { - githubOnce.Do(func() { - var client *http.Client - if token := os.Getenv("GITHUB_TOKEN"); len(token) > 0 { - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - client = oauth2.NewClient(ctx, ts) - } - githubClient = github.NewClient(client) - }) - return githubClient -} - -const ( - CLI_OWNER = "supabase" - CLI_REPO = "cli" -) - -func GetLatestRelease(ctx context.Context) (string, error) { - client := GetGitHubClient(ctx) - release, _, err := client.Repositories.GetLatestRelease(ctx, CLI_OWNER, CLI_REPO) - if err != nil { - return "", errors.Errorf("Failed to fetch latest release: %w", err) - } - if release.TagName == nil { - return "", nil - } - return *release.TagName, nil -} diff --git a/apps/cli-go/internal/utils/release_test.go b/apps/cli-go/internal/utils/release_test.go deleted file mode 100644 index 25aa920e8f..0000000000 --- a/apps/cli-go/internal/utils/release_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package utils - -import ( - "context" - "net/http" - "testing" - - "github.com/go-errors/errors" - "github.com/google/go-github/v62/github" - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/pkg/cast" -) - -func TestLatestRelease(t *testing.T) { - t.Run("fetches latest release", func(t *testing.T) { - // Setup api mock - defer gock.OffAll() - gock.New("https://api.github.com"). - Get("/repos/supabase/cli/releases/latest"). - Reply(http.StatusOK). - JSON(github.RepositoryRelease{TagName: cast.Ptr("v2")}) - // Run test - version, err := GetLatestRelease(context.Background()) - // Check error - assert.NoError(t, err) - assert.Equal(t, version, "v2") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("ignores missing version", func(t *testing.T) { - // Setup api mock - defer gock.OffAll() - gock.New("https://api.github.com"). - Get("/repos/supabase/cli/releases/latest"). - Reply(http.StatusOK). - JSON(github.RepositoryRelease{}) - // Run test - version, err := GetLatestRelease(context.Background()) - // Check error - assert.NoError(t, err) - assert.Empty(t, version) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on network error", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup api mock - defer gock.OffAll() - gock.New("https://api.github.com"). - Get("/repos/supabase/cli/releases/latest"). - ReplyError(errNetwork) - // Run test - version, err := GetLatestRelease(context.Background()) - // Check error - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, version) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/utils/render.go b/apps/cli-go/internal/utils/render.go deleted file mode 100644 index ee4a4dc6c5..0000000000 --- a/apps/cli-go/internal/utils/render.go +++ /dev/null @@ -1,60 +0,0 @@ -package utils - -import ( - "fmt" - "time" -) - -const ( - layoutVersion = "20060102150405" - layoutHuman = "2006-01-02 15:04:05" -) - -func FormatTime(t time.Time) string { - return t.UTC().Format(layoutHuman) -} - -func FormatTimestamp(timestamp string) string { - return parse(time.RFC3339, timestamp) -} - -func FormatTimestampVersion(timestamp string) string { - return parse(layoutVersion, timestamp) -} - -func parse(layout, value string) string { - t, err := time.Parse(layout, value) - if err != nil { - fmt.Fprintln(GetDebugLogger(), err) - return value - } - return FormatTime(t) -} - -var regionMap = map[string]string{ - "ap-east-1": "East Asia (Hong Kong)", - "ap-northeast-1": "Northeast Asia (Tokyo)", - "ap-northeast-2": "Northeast Asia (Seoul)", - "ap-south-1": "South Asia (Mumbai)", - "ap-southeast-1": "Southeast Asia (Singapore)", - "ap-southeast-2": "Oceania (Sydney)", - "ca-central-1": "Canada (Central)", - "eu-central-1": "Central EU (Frankfurt)", - "eu-central-2": "Central Europe (Zurich)", - "eu-north-1": "North EU (Stockholm)", - "eu-west-1": "West EU (Ireland)", - "eu-west-2": "West Europe (London)", - "eu-west-3": "West EU (Paris)", - "sa-east-1": "South America (São Paulo)", - "us-east-1": "East US (North Virginia)", - "us-east-2": "East US (Ohio)", - "us-west-1": "West US (North California)", - "us-west-2": "West US (Oregon)", -} - -func FormatRegion(region string) string { - if readable, ok := regionMap[region]; ok { - return readable - } - return region -} diff --git a/apps/cli-go/internal/utils/retry.go b/apps/cli-go/internal/utils/retry.go deleted file mode 100644 index 6f0fb57e1d..0000000000 --- a/apps/cli-go/internal/utils/retry.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/cenkalti/backoff/v4" -) - -const maxRetries = 8 - -func NewBackoffPolicy(ctx context.Context) backoff.BackOffContext { - b := backoff.NewExponentialBackOff(backoff.WithInitialInterval(3 * time.Second)) - return backoff.WithContext(backoff.WithMaxRetries(b, maxRetries), ctx) -} - -func NewErrorCallback(callbacks ...func(attempt uint) error) backoff.Notify { - failureCount := uint(0) - logger := GetDebugLogger() - return func(err error, d time.Duration) { - failureCount += 1 - if failureCount*3 > maxRetries { - logger = os.Stderr - } - for _, cb := range callbacks { - if err := cb(failureCount); err != nil { - fmt.Fprintln(logger, err) - } - } - fmt.Fprintln(logger, err) - fmt.Fprintf(logger, "Retry (%d/%d): ", failureCount, maxRetries) - } -} diff --git a/apps/cli-go/internal/utils/slice.go b/apps/cli-go/internal/utils/slice.go deleted file mode 100644 index 9babf6c59c..0000000000 --- a/apps/cli-go/internal/utils/slice.go +++ /dev/null @@ -1,12 +0,0 @@ -package utils - -func RemoveDuplicates[T comparable](slice []T) (result []T) { - set := make(map[T]struct{}) - for _, item := range slice { - if _, exists := set[item]; !exists { - set[item] = struct{}{} - result = append(result, item) - } - } - return result -} diff --git a/apps/cli-go/internal/utils/slice_test.go b/apps/cli-go/internal/utils/slice_test.go deleted file mode 100644 index 3c2a3e9575..0000000000 --- a/apps/cli-go/internal/utils/slice_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package utils - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRemoveDuplicates(t *testing.T) { - t.Run("string slice", func(t *testing.T) { - input := []string{"a", "b", "a", "c", "b", "d"} - expected := []string{"a", "b", "c", "d"} - assert.Equal(t, expected, RemoveDuplicates(input)) - }) - - t.Run("int slice", func(t *testing.T) { - input := []int{1, 2, 2, 3, 1, 4, 3, 5} - expected := []int{1, 2, 3, 4, 5} - assert.Equal(t, expected, RemoveDuplicates(input)) - }) - - t.Run("empty slice", func(t *testing.T) { - assert.Empty(t, RemoveDuplicates([]string{})) - }) - - t.Run("no duplicates", func(t *testing.T) { - input := []int{1, 2, 3, 4, 5} - assert.Equal(t, input, RemoveDuplicates(input)) - }) -} diff --git a/apps/cli-go/internal/utils/supabase_home.go b/apps/cli-go/internal/utils/supabase_home.go deleted file mode 100644 index 02dba18b10..0000000000 --- a/apps/cli-go/internal/utils/supabase_home.go +++ /dev/null @@ -1,24 +0,0 @@ -package utils - -import ( - "os" - "path/filepath" - "strings" - - "github.com/go-errors/errors" -) - -// SupabaseHomeDir returns the global Supabase CLI state root. It is overridden -// by the SUPABASE_HOME environment variable when set to a non-empty value (an -// absolute path is expected; the value is used verbatim), otherwise it defaults -// to ~/.supabase. -func SupabaseHomeDir() (string, error) { - if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" { - return home, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", errors.Errorf("failed to get $HOME directory: %w", err) - } - return filepath.Join(home, ".supabase"), nil -} diff --git a/apps/cli-go/internal/utils/tea.go b/apps/cli-go/internal/utils/tea.go deleted file mode 100644 index b7965eebfc..0000000000 --- a/apps/cli-go/internal/utils/tea.go +++ /dev/null @@ -1,201 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/progress" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/wrap" - "golang.org/x/term" -) - -func NewProgram(model tea.Model, opts ...tea.ProgramOption) Program { - var p Program - if term.IsTerminal(int(os.Stdin.Fd())) { - p = tea.NewProgram(model, opts...) - } else { - p = newFakeProgram(model) - } - return p -} - -// An interface describing the parts of BubbleTea's Program that we actually use. -type Program interface { - Start() error - Send(msg tea.Msg) - Quit() -} - -func newFakeProgram(model tea.Model) *fakeProgram { - p := &fakeProgram{ - model: model, - } - return p -} - -// A dumb text implementation of BubbleTea's Program that allows -// for output to be piped to another program. -type fakeProgram struct { - model tea.Model -} - -func (p *fakeProgram) Start() error { - initCmd := p.model.Init() - message := initCmd() - if message != nil { - p.model.Update(message) - } - return nil -} - -func (p *fakeProgram) Send(msg tea.Msg) { - switch msg := msg.(type) { - case StatusMsg: - fmt.Println(msg) - case PsqlMsg: - if msg != nil { - fmt.Println(*msg) - } - } - - _, cmd := p.model.Update(msg) - if cmd != nil { - cmd() - } -} - -func (p *fakeProgram) Quit() { - p.Send(tea.Quit()) -} - -type ( - StatusMsg string - ProgressMsg *float64 - PsqlMsg *string -) - -type StatusWriter struct { - Program -} - -func (t StatusWriter) Write(p []byte) (int, error) { - trimmed := bytes.TrimRight(p, "\n") - t.Send(StatusMsg(trimmed)) - return len(p), nil -} - -func RunProgram(ctx context.Context, f func(p Program, ctx context.Context) error) error { - ctx, cancel := context.WithCancel(ctx) - p := NewProgram(logModel{ - cancel: cancel, - spinner: spinner.New( - spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("205"))), - ), - }) - - errCh := make(chan error, 1) - go func() { - errCh <- f(p, ctx) - p.Quit() - }() - - if err := p.Start(); err != nil { - return err - } - return <-errCh -} - -type logModel struct { - cancel context.CancelFunc - - spinner spinner.Model - status string - progress *progress.Model - psqlOutputs []string - - width int -} - -func (m logModel) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m logModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC: - if m.cancel != nil { - m.cancel() - } - return m, tea.Quit - default: - return m, nil - } - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - case spinner.TickMsg: - spinnerModel, cmd := m.spinner.Update(msg) - m.spinner = spinnerModel - return m, cmd - case progress.FrameMsg: - if m.progress == nil { - return m, nil - } - - tmp, cmd := m.progress.Update(msg) - progressModel := tmp.(progress.Model) - m.progress = &progressModel - return m, cmd - case StatusMsg: - m.status = string(msg) - return m, nil - case ProgressMsg: - if msg == nil { - m.progress = nil - return m, nil - } - - if m.progress == nil { - progressModel := progress.New(progress.WithGradient("#1c1c1c", "#34b27b")) - m.progress = &progressModel - } - - return m, m.progress.SetPercent(*msg) - case PsqlMsg: - if msg == nil { - m.psqlOutputs = []string{} - return m, nil - } - - m.psqlOutputs = append(m.psqlOutputs, *msg) - if len(m.psqlOutputs) > 5 { - m.psqlOutputs = m.psqlOutputs[1:] - } - return m, nil - default: - return m, nil - } -} - -func (m logModel) View() string { - var progress string - if m.progress != nil { - progress = "\n\n" + m.progress.View() - } - - var psqlOutputs string - if len(m.psqlOutputs) > 0 { - psqlOutputs = "\n\n" + strings.Join(m.psqlOutputs, "\n") - } - - return wrap.String(m.spinner.View()+m.status+progress+psqlOutputs, m.width) -} diff --git a/apps/cli-go/internal/utils/templates/globals.sql b/apps/cli-go/internal/utils/templates/globals.sql deleted file mode 100644 index 0b8fbaef01..0000000000 --- a/apps/cli-go/internal/utils/templates/globals.sql +++ /dev/null @@ -1,146 +0,0 @@ --- --- PostgreSQL database cluster dump --- - -SET default_transaction_read_only = off; - -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; - --- --- Roles --- - -CREATE ROLE anon; -ALTER ROLE anon WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; -CREATE ROLE authenticated; -ALTER ROLE authenticated WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; -CREATE ROLE authenticator; -ALTER ROLE authenticator WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'postgres'; -CREATE ROLE dashboard_user; -ALTER ROLE dashboard_user WITH NOSUPERUSER INHERIT CREATEROLE CREATEDB NOLOGIN REPLICATION NOBYPASSRLS; -CREATE ROLE pgbouncer; -ALTER ROLE pgbouncer WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'postgres'; --- CREATE ROLE pgsodium_keyholder; --- ALTER ROLE pgsodium_keyholder WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; --- CREATE ROLE pgsodium_keyiduser; --- ALTER ROLE pgsodium_keyiduser WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; --- CREATE ROLE pgsodium_keymaker; --- ALTER ROLE pgsodium_keymaker WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS; --- CREATE ROLE postgres; --- ALTER ROLE postgres WITH NOSUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPASSRLS; -CREATE ROLE service_role; -ALTER ROLE service_role WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION BYPASSRLS; -CREATE ROLE supabase_admin; -ALTER ROLE supabase_admin WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPASSRLS PASSWORD 'postgres'; -CREATE ROLE supabase_auth_admin; -ALTER ROLE supabase_auth_admin WITH NOSUPERUSER NOINHERIT CREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'postgres'; -CREATE ROLE supabase_functions_admin; -ALTER ROLE supabase_functions_admin WITH NOSUPERUSER NOINHERIT CREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'postgres'; -CREATE ROLE supabase_read_only_user; -ALTER ROLE supabase_read_only_user WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION BYPASSRLS PASSWORD 'postgres'; -CREATE ROLE supabase_replication_admin; -ALTER ROLE supabase_replication_admin WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN REPLICATION NOBYPASSRLS PASSWORD 'postgres'; -CREATE ROLE supabase_storage_admin; -ALTER ROLE supabase_storage_admin WITH NOSUPERUSER NOINHERIT CREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'postgres'; - --- --- User Configurations --- - --- --- User Config "anon" --- - -ALTER ROLE anon SET statement_timeout TO '3s'; - --- --- User Config "authenticated" --- - -ALTER ROLE authenticated SET statement_timeout TO '8s'; - --- --- User Config "authenticator" --- - -ALTER ROLE authenticator SET session_preload_libraries TO 'safeupdate'; -ALTER ROLE authenticator SET statement_timeout TO '8s'; - --- --- User Config "postgres" --- - -ALTER ROLE postgres SET search_path TO E'\\$user', 'public', 'extensions'; - --- --- User Config "supabase_admin" --- - -ALTER ROLE supabase_admin SET search_path TO E'\\$user', 'public', 'auth', 'extensions'; - --- --- User Config "supabase_auth_admin" --- - -ALTER ROLE supabase_auth_admin SET search_path TO 'auth'; -ALTER ROLE supabase_auth_admin SET idle_in_transaction_session_timeout TO '60000'; - --- --- User Config "supabase_functions_admin" --- - -ALTER ROLE supabase_functions_admin SET search_path TO 'supabase_functions'; - --- --- User Config "supabase_storage_admin" --- - -ALTER ROLE supabase_storage_admin SET search_path TO 'storage'; - - --- --- Role memberships --- - -GRANT anon TO authenticator GRANTED BY postgres; -GRANT anon TO postgres GRANTED BY supabase_admin; -GRANT anon TO supabase_storage_admin GRANTED BY supabase_admin; -GRANT authenticated TO authenticator GRANTED BY postgres; -GRANT authenticated TO postgres GRANTED BY supabase_admin; -GRANT authenticated TO supabase_storage_admin GRANTED BY supabase_admin; -GRANT pg_monitor TO postgres GRANTED BY supabase_admin; --- GRANT pg_read_all_data TO supabase_read_only_user GRANTED BY postgres; --- GRANT pgsodium_keyholder TO pgsodium_keymaker GRANTED BY postgres; --- GRANT pgsodium_keyholder TO postgres WITH ADMIN OPTION GRANTED BY postgres; --- GRANT pgsodium_keyiduser TO pgsodium_keyholder GRANTED BY postgres; --- GRANT pgsodium_keyiduser TO pgsodium_keymaker GRANTED BY postgres; --- GRANT pgsodium_keyiduser TO postgres WITH ADMIN OPTION GRANTED BY postgres; --- GRANT pgsodium_keymaker TO postgres WITH ADMIN OPTION GRANTED BY postgres; -GRANT service_role TO authenticator GRANTED BY postgres; -GRANT service_role TO postgres GRANTED BY supabase_admin; -GRANT service_role TO supabase_storage_admin GRANTED BY supabase_admin; -GRANT supabase_auth_admin TO postgres GRANTED BY supabase_admin; -GRANT supabase_functions_admin TO postgres GRANTED BY supabase_admin; -GRANT supabase_storage_admin TO postgres GRANTED BY supabase_admin; - - - - --- --- PostgreSQL database cluster dump complete --- - -DO $$ -BEGIN - -- role pg_read_all_data is not available on pg13 - IF EXISTS ( - SELECT FROM pg_catalog.pg_roles - WHERE rolname = 'pg_read_all_data' - ) THEN - GRANT pg_read_all_data TO supabase_read_only_user GRANTED BY postgres; - END IF; -END -$$; - -RESET ALL; diff --git a/apps/cli-go/internal/utils/templates/initial_schemas/13.sql b/apps/cli-go/internal/utils/templates/initial_schemas/13.sql deleted file mode 100644 index 135ce9a516..0000000000 --- a/apps/cli-go/internal/utils/templates/initial_schemas/13.sql +++ /dev/null @@ -1,3094 +0,0 @@ --- --- PostgreSQL database dump --- - --- Dumped from database version 13.3 (Debian 13.3-1.pgdg100+1) --- Dumped by pg_dump version 15.3 - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Name: _analytics; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS _analytics; - - -ALTER SCHEMA _analytics OWNER TO postgres; - --- --- Name: _realtime; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS _realtime; - - -ALTER SCHEMA _realtime OWNER TO postgres; - --- --- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS auth; - - -ALTER SCHEMA auth OWNER TO supabase_admin; - --- --- Name: extensions; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS extensions; - - -ALTER SCHEMA extensions OWNER TO postgres; - --- --- Name: graphql_public; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS graphql_public; - - -ALTER SCHEMA graphql_public OWNER TO supabase_admin; - --- --- Name: public; Type: SCHEMA; Schema: -; Owner: postgres --- - --- *not* creating schema, since initdb creates it - - -ALTER SCHEMA public OWNER TO postgres; - --- --- Name: realtime; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS realtime; - - -ALTER SCHEMA realtime OWNER TO supabase_admin; - --- --- Name: storage; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS storage; - - -ALTER SCHEMA storage OWNER TO supabase_admin; - --- --- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; - - --- --- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - --- --- Name: pgjwt; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions; - - --- --- Name: EXTENSION pgjwt; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgjwt IS 'JSON Web Token API for Postgresql'; - - --- --- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; - - --- --- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; - - --- --- Name: aal_level; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.aal_level AS ENUM ( - 'aal1', - 'aal2', - 'aal3' -); - - -ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin; - --- --- Name: code_challenge_method; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.code_challenge_method AS ENUM ( - 's256', - 'plain' -); - - -ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin; - --- --- Name: factor_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.factor_status AS ENUM ( - 'unverified', - 'verified' -); - - -ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin; - --- --- Name: factor_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.factor_type AS ENUM ( - 'totp', - 'webauthn' -); - - -ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin; - --- --- Name: email(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.email() RETURNS text - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.email', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') - )::text -$$; - - -ALTER FUNCTION auth.email() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION email(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.'; - - --- --- Name: jwt(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb -$$; - - -ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; - --- --- Name: role(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.role() RETURNS text - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.role', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') - )::text -$$; - - -ALTER FUNCTION auth.role() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION role(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.'; - - --- --- Name: uid(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.sub', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') - )::uuid -$$; - - -ALTER FUNCTION auth.uid() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION uid(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; - - --- --- Name: grant_pg_cron_access(); Type: FUNCTION; Schema: extensions; Owner: postgres --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - schema_is_cron bool; -BEGIN - schema_is_cron = ( - SELECT n.nspname = 'cron' - FROM pg_event_trigger_ddl_commands() AS ev - LEFT JOIN pg_catalog.pg_namespace AS n - ON ev.objid = n.oid - ); - - IF schema_is_cron - THEN - grant usage on schema cron to postgres with grant option; - - alter default privileges in schema cron grant all on tables to postgres with grant option; - alter default privileges in schema cron grant all on functions to postgres with grant option; - alter default privileges in schema cron grant all on sequences to postgres with grant option; - - alter default privileges for user supabase_admin in schema cron grant all - on sequences to postgres with grant option; - alter default privileges for user supabase_admin in schema cron grant all - on tables to postgres with grant option; - alter default privileges for user supabase_admin in schema cron grant all - on functions to postgres with grant option; - - grant all privileges on all tables in schema cron to postgres with grant option; - - END IF; - -END; -$$; - - -ALTER FUNCTION extensions.grant_pg_cron_access() OWNER TO postgres; - --- --- Name: FUNCTION grant_pg_cron_access(); Type: COMMENT; Schema: extensions; Owner: postgres --- - -COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cron'; - - --- --- Name: grant_pg_graphql_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $_$ -DECLARE - func_is_graphql_resolve bool; -BEGIN - func_is_graphql_resolve = ( - SELECT n.proname = 'resolve' - FROM pg_event_trigger_ddl_commands() AS ev - LEFT JOIN pg_catalog.pg_proc AS n - ON ev.objid = n.oid - ); - - IF func_is_graphql_resolve - THEN - - -- Update public wrapper to pass all arguments through to the pg_graphql resolve func - create or replace function graphql_public.graphql( - "operationName" text default null, - query text default null, - variables jsonb default null, - extensions jsonb default null - ) - returns jsonb - language sql - as $$ - select graphql.resolve( - query := query, - variables := coalesce(variables, '{}'), - "operationName" := "operationName", - extensions := extensions - ); - $$; - - -- This hook executes when `graphql.resolve` is created. That is not necessarily the last - -- function in the extension so we need to grant permissions on existing entities AND - -- update default permissions to any others that are created after `graphql.resolve` - grant usage on schema graphql to postgres, anon, authenticated, service_role; - grant select on all tables in schema graphql to postgres, anon, authenticated, service_role; - grant execute on all functions in schema graphql to postgres, anon, authenticated, service_role; - grant all on all sequences in schema graphql to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role; - END IF; - -END; -$_$; - - -ALTER FUNCTION extensions.grant_pg_graphql_access() OWNER TO supabase_admin; - --- --- Name: FUNCTION grant_pg_graphql_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin --- - -COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg_graphql'; - - --- --- Name: grant_pg_net_access(); Type: FUNCTION; Schema: extensions; Owner: postgres --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - IF NOT EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_functions_admin' - ) - THEN - CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; - END IF; - - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_collect_response(request_id bigint, async boolean) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_collect_response(request_id bigint, async boolean) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$$; - - -ALTER FUNCTION extensions.grant_pg_net_access() OWNER TO postgres; - --- --- Name: FUNCTION grant_pg_net_access(); Type: COMMENT; Schema: extensions; Owner: postgres --- - -COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net'; - - --- --- Name: pgrst_ddl_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - cmd record; -BEGIN - FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() - LOOP - IF cmd.command_tag IN ( - 'CREATE SCHEMA', 'ALTER SCHEMA' - , 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE' - , 'CREATE FOREIGN TABLE', 'ALTER FOREIGN TABLE' - , 'CREATE VIEW', 'ALTER VIEW' - , 'CREATE MATERIALIZED VIEW', 'ALTER MATERIALIZED VIEW' - , 'CREATE FUNCTION', 'ALTER FUNCTION' - , 'CREATE TRIGGER' - , 'CREATE TYPE', 'ALTER TYPE' - , 'CREATE RULE' - , 'COMMENT' - ) - -- don't notify in case of CREATE TEMP table or other objects created on pg_temp - AND cmd.schema_name is distinct from 'pg_temp' - THEN - NOTIFY pgrst, 'reload schema'; - END IF; - END LOOP; -END; $$; - - -ALTER FUNCTION extensions.pgrst_ddl_watch() OWNER TO supabase_admin; - --- --- Name: pgrst_drop_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - obj record; -BEGIN - FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() - LOOP - IF obj.object_type IN ( - 'schema' - , 'table' - , 'foreign table' - , 'view' - , 'materialized view' - , 'function' - , 'trigger' - , 'type' - , 'rule' - ) - AND obj.is_temporary IS false -- no pg_temp objects - THEN - NOTIFY pgrst, 'reload schema'; - END IF; - END LOOP; -END; $$; - - -ALTER FUNCTION extensions.pgrst_drop_watch() OWNER TO supabase_admin; - --- --- Name: set_graphql_placeholder(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger - LANGUAGE plpgsql - AS $_$ - DECLARE - graphql_is_dropped bool; - BEGIN - graphql_is_dropped = ( - SELECT ev.schema_name = 'graphql_public' - FROM pg_event_trigger_dropped_objects() AS ev - WHERE ev.schema_name = 'graphql_public' - ); - - IF graphql_is_dropped - THEN - create or replace function graphql_public.graphql( - "operationName" text default null, - query text default null, - variables jsonb default null, - extensions jsonb default null - ) - returns jsonb - language plpgsql - as $$ - DECLARE - server_version float; - BEGIN - server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); - - IF server_version >= 14 THEN - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql extension is not enabled.' - ) - ) - ); - ELSE - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql is only available on projects running Postgres 14 onwards.' - ) - ) - ); - END IF; - END; - $$; - END IF; - - END; -$_$; - - -ALTER FUNCTION extensions.set_graphql_placeholder() OWNER TO supabase_admin; - --- --- Name: FUNCTION set_graphql_placeholder(); Type: COMMENT; Schema: extensions; Owner: supabase_admin --- - -COMMENT ON FUNCTION extensions.set_graphql_placeholder() IS 'Reintroduces placeholder function for graphql_public.graphql'; - - --- --- Name: graphql(text, text, jsonb, jsonb); Type: FUNCTION; Schema: graphql_public; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION graphql_public.graphql("operationName" text DEFAULT NULL::text, query text DEFAULT NULL::text, variables jsonb DEFAULT NULL::jsonb, extensions jsonb DEFAULT NULL::jsonb) RETURNS jsonb - LANGUAGE plpgsql - AS $$ - DECLARE - server_version float; - BEGIN - server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); - - IF server_version >= 14 THEN - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql extension is not enabled.' - ) - ) - ); - ELSE - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql is only available on projects running Postgres 14 onwards.' - ) - ) - ); - END IF; - END; -$$; - - -ALTER FUNCTION graphql_public.graphql("operationName" text, query text, variables jsonb, extensions jsonb) OWNER TO supabase_admin; - --- --- Name: can_insert_object(text, text, uuid, jsonb); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void - LANGUAGE plpgsql - AS $$ -BEGIN - INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata); - -- hack to rollback the successful insert - RAISE sqlstate 'PT200' using - message = 'ROLLBACK', - detail = 'rollback successful insert'; -END -$$; - - -ALTER FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) OWNER TO supabase_storage_admin; - --- --- Name: extension(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.extension(name text) RETURNS text - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -_filename text; -BEGIN - select string_to_array(name, '/') into _parts; - select _parts[array_length(_parts,1)] into _filename; - -- @todo return the last part instead of 2 - return split_part(_filename, '.', 2); -END -$$; - - -ALTER FUNCTION storage.extension(name text) OWNER TO supabase_storage_admin; - --- --- Name: filename(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.filename(name text) RETURNS text - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[array_length(_parts,1)]; -END -$$; - - -ALTER FUNCTION storage.filename(name text) OWNER TO supabase_storage_admin; - --- --- Name: foldername(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.foldername(name text) RETURNS text[] - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[1:array_length(_parts,1)-1]; -END -$$; - - -ALTER FUNCTION storage.foldername(name text) OWNER TO supabase_storage_admin; - --- --- Name: get_size_by_bucket(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text) - LANGUAGE plpgsql - AS $$ -BEGIN - return query - select sum((metadata->>'size')::int) as size, obj.bucket_id - from "storage".objects as obj - group by obj.bucket_id; -END -$$; - - -ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin; - --- --- Name: search(text, text, integer, integer, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb) - LANGUAGE plpgsql STABLE - AS $_$ -declare - v_order_by text; - v_sort_order text; -begin - case - when sortcolumn = 'name' then - v_order_by = 'name'; - when sortcolumn = 'updated_at' then - v_order_by = 'updated_at'; - when sortcolumn = 'created_at' then - v_order_by = 'created_at'; - when sortcolumn = 'last_accessed_at' then - v_order_by = 'last_accessed_at'; - else - v_order_by = 'name'; - end case; - - case - when sortorder = 'asc' then - v_sort_order = 'asc'; - when sortorder = 'desc' then - v_sort_order = 'desc'; - else - v_sort_order = 'asc'; - end case; - - v_order_by = v_order_by || ' ' || v_sort_order; - - return query execute - 'with folders as ( - select path_tokens[$1] as folder - from storage.objects - where objects.name ilike $2 || $3 || ''%'' - and bucket_id = $4 - and array_length(regexp_split_to_array(objects.name, ''/''), 1) <> $1 - group by folder - order by folder ' || v_sort_order || ' - ) - (select folder as "name", - null as id, - null as updated_at, - null as created_at, - null as last_accessed_at, - null as metadata from folders) - union all - (select path_tokens[$1] as "name", - id, - updated_at, - created_at, - last_accessed_at, - metadata - from storage.objects - where objects.name ilike $2 || $3 || ''%'' - and bucket_id = $4 - and array_length(regexp_split_to_array(objects.name, ''/''), 1) = $1 - order by ' || v_order_by || ') - limit $5 - offset $6' using levels, prefix, search, bucketname, limits, offsets; -end; -$_$; - - -ALTER FUNCTION storage.search(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text) OWNER TO supabase_storage_admin; - --- --- Name: update_updated_at_column(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.update_updated_at_column() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$; - - -ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin; - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: extensions; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.extensions ( - id uuid NOT NULL, - type text, - settings jsonb, - tenant_external_id text, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL -); - - -ALTER TABLE _realtime.extensions OWNER TO postgres; - --- --- Name: schema_migrations; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - - -ALTER TABLE _realtime.schema_migrations OWNER TO postgres; - --- --- Name: tenants; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.tenants ( - id uuid NOT NULL, - name text, - external_id text, - jwt_secret text, - max_concurrent_users integer DEFAULT 200 NOT NULL, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL, - max_events_per_second integer DEFAULT 100 NOT NULL, - postgres_cdc_default text DEFAULT 'postgres_cdc_rls'::text, - max_bytes_per_second integer DEFAULT 100000 NOT NULL, - max_channels_per_client integer DEFAULT 100 NOT NULL, - max_joins_per_second integer DEFAULT 500 NOT NULL, - suspend boolean DEFAULT false -); - - -ALTER TABLE _realtime.tenants OWNER TO postgres; - --- --- Name: audit_log_entries; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.audit_log_entries ( - instance_id uuid, - id uuid NOT NULL, - payload json, - created_at timestamp with time zone, - ip_address character varying(64) DEFAULT ''::character varying NOT NULL -); - - -ALTER TABLE auth.audit_log_entries OWNER TO supabase_auth_admin; - --- --- Name: TABLE audit_log_entries; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.audit_log_entries IS 'Auth: Audit trail for user actions.'; - - --- --- Name: flow_state; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.flow_state ( - id uuid NOT NULL, - user_id uuid, - auth_code text NOT NULL, - code_challenge_method auth.code_challenge_method NOT NULL, - code_challenge text NOT NULL, - provider_type text NOT NULL, - provider_access_token text, - provider_refresh_token text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - authentication_method text NOT NULL -); - - -ALTER TABLE auth.flow_state OWNER TO supabase_auth_admin; - --- --- Name: TABLE flow_state; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.flow_state IS 'stores metadata for pkce logins'; - - --- --- Name: identities; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.identities ( - provider_id text NOT NULL, - user_id uuid NOT NULL, - identity_data jsonb NOT NULL, - provider text NOT NULL, - last_sign_in_at timestamp with time zone, - created_at timestamp with time zone, - updated_at timestamp with time zone, - email text GENERATED ALWAYS AS (lower((identity_data ->> 'email'::text))) STORED, - id uuid DEFAULT gen_random_uuid() NOT NULL -); - - -ALTER TABLE auth.identities OWNER TO supabase_auth_admin; - --- --- Name: TABLE identities; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.'; - - --- --- Name: COLUMN identities.email; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data'; - - --- --- Name: instances; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.instances ( - id uuid NOT NULL, - uuid uuid, - raw_base_config text, - created_at timestamp with time zone, - updated_at timestamp with time zone -); - - -ALTER TABLE auth.instances OWNER TO supabase_auth_admin; - --- --- Name: TABLE instances; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.instances IS 'Auth: Manages users across multiple sites.'; - - --- --- Name: mfa_amr_claims; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_amr_claims ( - session_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - authentication_method text NOT NULL, - id uuid NOT NULL -); - - -ALTER TABLE auth.mfa_amr_claims OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_amr_claims; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication'; - - --- --- Name: mfa_challenges; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_challenges ( - id uuid NOT NULL, - factor_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - verified_at timestamp with time zone, - ip_address inet NOT NULL -); - - -ALTER TABLE auth.mfa_challenges OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_challenges; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made'; - - --- --- Name: mfa_factors; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_factors ( - id uuid NOT NULL, - user_id uuid NOT NULL, - friendly_name text, - factor_type auth.factor_type NOT NULL, - status auth.factor_status NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - secret text -); - - -ALTER TABLE auth.mfa_factors OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_factors; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors'; - - --- --- Name: refresh_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.refresh_tokens ( - instance_id uuid, - id bigint NOT NULL, - token character varying(255), - user_id character varying(255), - revoked boolean, - created_at timestamp with time zone, - updated_at timestamp with time zone, - parent character varying(255), - session_id uuid -); - - -ALTER TABLE auth.refresh_tokens OWNER TO supabase_auth_admin; - --- --- Name: TABLE refresh_tokens; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.refresh_tokens IS 'Auth: Store of tokens used to refresh JWT tokens once they expire.'; - - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE SEQUENCE IF NOT EXISTS auth.refresh_tokens_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE auth.refresh_tokens_id_seq OWNER TO supabase_auth_admin; - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: auth; Owner: supabase_auth_admin --- - -ALTER SEQUENCE auth.refresh_tokens_id_seq OWNED BY auth.refresh_tokens.id; - - --- --- Name: saml_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.saml_providers ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - entity_id text NOT NULL, - metadata_xml text NOT NULL, - metadata_url text, - attribute_mapping jsonb, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "entity_id not empty" CHECK ((char_length(entity_id) > 0)), - CONSTRAINT "metadata_url not empty" CHECK (((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))), - CONSTRAINT "metadata_xml not empty" CHECK ((char_length(metadata_xml) > 0)) -); - - -ALTER TABLE auth.saml_providers OWNER TO supabase_auth_admin; - --- --- Name: TABLE saml_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.'; - - --- --- Name: saml_relay_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.saml_relay_states ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - request_id text NOT NULL, - for_email text, - redirect_to text, - from_ip_address inet, - created_at timestamp with time zone, - updated_at timestamp with time zone, - flow_state_id uuid, - CONSTRAINT "request_id not empty" CHECK ((char_length(request_id) > 0)) -); - - -ALTER TABLE auth.saml_relay_states OWNER TO supabase_auth_admin; - --- --- Name: TABLE saml_relay_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.'; - - --- --- Name: schema_migrations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.schema_migrations ( - version character varying(255) NOT NULL -); - - -ALTER TABLE auth.schema_migrations OWNER TO supabase_auth_admin; - --- --- Name: TABLE schema_migrations; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.schema_migrations IS 'Auth: Manages updates to the auth system.'; - - --- --- Name: sessions; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sessions ( - id uuid NOT NULL, - user_id uuid NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - factor_id uuid, - aal auth.aal_level, - not_after timestamp with time zone, - refreshed_at timestamp without time zone, - user_agent text, - ip inet, - tag text -); - - -ALTER TABLE auth.sessions OWNER TO supabase_auth_admin; - --- --- Name: TABLE sessions; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.'; - - --- --- Name: COLUMN sessions.not_after; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.'; - - --- --- Name: sso_domains; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sso_domains ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - domain text NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "domain not empty" CHECK ((char_length(domain) > 0)) -); - - -ALTER TABLE auth.sso_domains OWNER TO supabase_auth_admin; - --- --- Name: TABLE sso_domains; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.'; - - --- --- Name: sso_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sso_providers ( - id uuid NOT NULL, - resource_id text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "resource_id not empty" CHECK (((resource_id = NULL::text) OR (char_length(resource_id) > 0))) -); - - -ALTER TABLE auth.sso_providers OWNER TO supabase_auth_admin; - --- --- Name: TABLE sso_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.'; - - --- --- Name: COLUMN sso_providers.resource_id; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.'; - - --- --- Name: users; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.users ( - instance_id uuid, - id uuid NOT NULL, - aud character varying(255), - role character varying(255), - email character varying(255), - encrypted_password character varying(255), - email_confirmed_at timestamp with time zone, - invited_at timestamp with time zone, - confirmation_token character varying(255), - confirmation_sent_at timestamp with time zone, - recovery_token character varying(255), - recovery_sent_at timestamp with time zone, - email_change_token_new character varying(255), - email_change character varying(255), - email_change_sent_at timestamp with time zone, - last_sign_in_at timestamp with time zone, - raw_app_meta_data jsonb, - raw_user_meta_data jsonb, - is_super_admin boolean, - created_at timestamp with time zone, - updated_at timestamp with time zone, - phone text DEFAULT NULL::character varying, - phone_confirmed_at timestamp with time zone, - phone_change text DEFAULT ''::character varying, - phone_change_token character varying(255) DEFAULT ''::character varying, - phone_change_sent_at timestamp with time zone, - confirmed_at timestamp with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED, - email_change_token_current character varying(255) DEFAULT ''::character varying, - email_change_confirm_status smallint DEFAULT 0, - banned_until timestamp with time zone, - reauthentication_token character varying(255) DEFAULT ''::character varying, - reauthentication_sent_at timestamp with time zone, - is_sso_user boolean DEFAULT false NOT NULL, - deleted_at timestamp with time zone, - CONSTRAINT users_email_change_confirm_status_check CHECK (((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))) -); - - -ALTER TABLE auth.users OWNER TO supabase_auth_admin; - --- --- Name: TABLE users; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.users IS 'Auth: Stores user login data within a secure schema.'; - - --- --- Name: COLUMN users.is_sso_user; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.'; - - --- --- Name: buckets; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.buckets ( - id text NOT NULL, - name text NOT NULL, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - public boolean DEFAULT false, - avif_autodetection boolean DEFAULT false, - file_size_limit bigint, - allowed_mime_types text[], - owner_id text -); - - -ALTER TABLE storage.buckets OWNER TO supabase_storage_admin; - --- --- Name: COLUMN buckets.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin --- - -COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead'; - - --- --- Name: migrations; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.migrations ( - id integer NOT NULL, - name character varying(100) NOT NULL, - hash character varying(40) NOT NULL, - executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP -); - - -ALTER TABLE storage.migrations OWNER TO supabase_storage_admin; - --- --- Name: objects; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.objects ( - id uuid DEFAULT extensions.uuid_generate_v4() NOT NULL, - bucket_id text, - name text, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - last_accessed_at timestamp with time zone DEFAULT now(), - metadata jsonb, - path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) STORED, - version text, - owner_id text -); - - -ALTER TABLE storage.objects OWNER TO supabase_storage_admin; - --- --- Name: COLUMN objects.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin --- - -COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead'; - - --- --- Name: refresh_tokens id; Type: DEFAULT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens ALTER COLUMN id SET DEFAULT nextval('auth.refresh_tokens_id_seq'::regclass); - - --- --- Data for Name: extensions; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.extensions (id, type, settings, tenant_external_id, inserted_at, updated_at) VALUES ('5518e2da-548e-499c-ab1c-664fd6446714', 'postgres_cdc_rls', '{"region": "us-east-1", "db_host": "ABK7kBu27y/PVdL10i/b+A==", "db_name": "sWBpZNdjggEPTQVlI52Zfw==", "db_port": "+enMDFi1J/3IrrquHHwUmA==", "db_user": "uxbEq/zz8DXVD53TOI1zmw==", "slot_name": "supabase_realtime_replication_slot", "ip_version": 4, "db_password": "sWBpZNdjggEPTQVlI52Zfw==", "publication": "supabase_realtime", "ssl_enforced": false, "poll_interval_ms": 100, "poll_max_changes": 100, "poll_max_record_bytes": 1048576}', 'realtime-dev', '2023-12-12 15:25:21', '2023-12-12 15:25:21'); - - --- --- Data for Name: schema_migrations; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20210706140551, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220329161857, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220410212326, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220506102948, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220527210857, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220815211129, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220815215024, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220818141501, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221018173709, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221102172703, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221223010058, '2023-01-09 07:15:13'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230110180046, '2023-04-06 09:25:28'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230810220907, '2023-12-12 15:25:19'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230810220924, '2023-12-12 15:25:19'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20231024094642, '2023-12-12 15:25:19'); - - --- --- Data for Name: tenants; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.tenants (id, name, external_id, jwt_secret, max_concurrent_users, inserted_at, updated_at, max_events_per_second, postgres_cdc_default, max_bytes_per_second, max_channels_per_client, max_joins_per_second, suspend) VALUES ('e6edd492-6586-4c5d-9c2e-4e4de2e5c64d', 'realtime-dev', 'realtime-dev', 'iNjicxc4+llvc9wovDvqymwfnj9teWMlyOIbJ8Fh6j2WNU8CIJ2ZgjR6MUIKqSmeDmvpsKLsZ9jgXJmQPpwL8w==', 200, '2023-12-12 15:25:21', '2023-12-12 15:25:21', 100, 'postgres_cdc_rls', 100000, 100, 100, false); - - --- --- Data for Name: audit_log_entries; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: flow_state; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: instances; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_amr_claims; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_challenges; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_factors; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: saml_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: saml_relay_states; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: schema_migrations; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211738'); -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211808'); -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211834'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180103212743'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180108183307'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180119214651'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180125194653'); -INSERT INTO auth.schema_migrations (version) VALUES ('00'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210710035447'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210722035447'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210730183235'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210909172000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210927181326'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211122151130'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211124214934'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211202183645'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220114185221'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220114185340'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220224000811'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220323170000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220429102000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220531120530'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220614074223'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220811173540'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221003041349'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221003041400'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221011041400'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221020193600'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221021073300'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221021082433'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221027105023'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221114143122'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221114143410'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221125140132'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221208132122'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195500'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195800'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195900'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230116124310'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230116124412'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230131181311'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230322519590'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230402418590'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230411005111'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230508135423'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230523124323'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230818113222'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230914180801'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231027141322'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231114161723'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231117164230'); - - --- --- Data for Name: sessions; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: sso_domains; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: sso_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: users; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: buckets; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - - - --- --- Data for Name: migrations; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (0, 'create-migrations-table', 'e18db593bcde2aca2a408c4d1100f6abba2195df', '2022-04-14 09:52:39.214681'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (1, 'initialmigration', '6ab16121fbaa08bbd11b712d05f358f9b555d777', '2022-04-14 09:52:39.255149'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (2, 'pathtoken-column', '49756be03be4c17bb85fe70d4a861f27de7e49ad', '2022-04-14 09:52:39.276667'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (3, 'add-migrations-rls', 'bb5d124c53d68635a883e399426c6a5a25fc893d', '2022-04-14 09:52:39.587158'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (4, 'add-size-functions', '6d79007d04f5acd288c9c250c42d2d5fd286c54d', '2022-04-14 09:52:39.621747'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (5, 'change-column-name-in-get-size', 'fd65688505d2ffa9fbdc58a944348dd8604d688c', '2022-04-14 09:52:39.658796'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (6, 'add-rls-to-buckets', '63e2bab75a2040fee8e3fb3f15a0d26f3380e9b6', '2022-04-14 09:52:39.702011'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (7, 'add-public-to-buckets', '82568934f8a4d9e0a85f126f6fb483ad8214c418', '2022-04-14 09:52:39.739731'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (8, 'fix-search-function', '1a43a40eddb525f2e2f26efd709e6c06e58e059c', '2022-04-14 09:52:39.781062'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (9, 'search-files-search-function', '34c096597eb8b9d077fdfdde9878c88501b2fafc', '2022-08-23 04:48:07.728336'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (10, 'add-trigger-to-auto-update-updated_at-column', '37d6bb964a70a822e6d37f22f457b9bca7885928', '2022-08-23 04:48:07.736527'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (11, 'add-automatic-avif-detection-flag', 'bd76c53a9c564c80d98d119c1b3a28e16c8152db', '2023-04-06 09:25:29.356743'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (12, 'add-bucket-custom-limits', 'cbe0a4c32a0e891554a21020433b7a4423c07ee7', '2023-04-06 09:25:29.390303'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (13, 'use-bytes-for-max-size', '7a158ebce8a0c2801c9c65b7e9b2f98f68b3874e', '2023-04-06 09:25:29.402124'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (14, 'add-can-insert-object-function', '273193826bca7e0990b458d1ba72f8aa27c0d825', '2023-04-06 09:25:29.426206'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (15, 'add-version', 'e821a779d26612899b8c2dfe20245f904a327c4f', '2023-04-06 09:25:29.439561'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (16, 'drop-owner-foreign-key', '536b33f8878eed09d0144219777dcac96bdb25da', '2023-12-12 15:25:21.258059'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (17, 'add_owner_id_column_deprecate_owner', '7545f216a39358b5487df75d941d05dbcd75eb46', '2023-12-12 15:25:21.271667'); - - --- --- Data for Name: objects; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - - - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE SET; Schema: auth; Owner: supabase_auth_admin --- - -SELECT pg_catalog.setval('auth.refresh_tokens_id_seq', 1, false); - - --- --- Name: extensions extensions_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.extensions - ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - - --- --- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.tenants - ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); - - --- --- Name: mfa_amr_claims amr_id_pk; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT amr_id_pk PRIMARY KEY (id); - - --- --- Name: audit_log_entries audit_log_entries_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.audit_log_entries - ADD CONSTRAINT audit_log_entries_pkey PRIMARY KEY (id); - - --- --- Name: flow_state flow_state_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.flow_state - ADD CONSTRAINT flow_state_pkey PRIMARY KEY (id); - - --- --- Name: identities identities_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_pkey PRIMARY KEY (id); - - --- --- Name: identities identities_provider_id_provider_unique; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_provider_id_provider_unique UNIQUE (provider_id, provider); - - --- --- Name: instances instances_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.instances - ADD CONSTRAINT instances_pkey PRIMARY KEY (id); - - --- --- Name: mfa_amr_claims mfa_amr_claims_session_id_authentication_method_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey UNIQUE (session_id, authentication_method); - - --- --- Name: mfa_challenges mfa_challenges_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_pkey PRIMARY KEY (id); - - --- --- Name: mfa_factors mfa_factors_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_factors - ADD CONSTRAINT mfa_factors_pkey PRIMARY KEY (id); - - --- --- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); - - --- --- Name: refresh_tokens refresh_tokens_token_unique; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_token_unique UNIQUE (token); - - --- --- Name: saml_providers saml_providers_entity_id_key; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_entity_id_key UNIQUE (entity_id); - - --- --- Name: saml_providers saml_providers_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_pkey PRIMARY KEY (id); - - --- --- Name: saml_relay_states saml_relay_states_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - - --- --- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); - - --- --- Name: sso_domains sso_domains_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_domains - ADD CONSTRAINT sso_domains_pkey PRIMARY KEY (id); - - --- --- Name: sso_providers sso_providers_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_providers - ADD CONSTRAINT sso_providers_pkey PRIMARY KEY (id); - - --- --- Name: users users_phone_key; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.users - ADD CONSTRAINT users_phone_key UNIQUE (phone); - - --- --- Name: users users_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.users - ADD CONSTRAINT users_pkey PRIMARY KEY (id); - - --- --- Name: buckets buckets_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.buckets - ADD CONSTRAINT buckets_pkey PRIMARY KEY (id); - - --- --- Name: migrations migrations_name_key; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.migrations - ADD CONSTRAINT migrations_name_key UNIQUE (name); - - --- --- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); - - --- --- Name: objects objects_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.objects - ADD CONSTRAINT objects_pkey PRIMARY KEY (id); - - --- --- Name: extensions_tenant_external_id_type_index; Type: INDEX; Schema: _realtime; Owner: postgres --- - -CREATE UNIQUE INDEX extensions_tenant_external_id_type_index ON _realtime.extensions USING btree (tenant_external_id, type); - - --- --- Name: tenants_external_id_index; Type: INDEX; Schema: _realtime; Owner: postgres --- - -CREATE UNIQUE INDEX tenants_external_id_index ON _realtime.tenants USING btree (external_id); - - --- --- Name: audit_logs_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX audit_logs_instance_id_idx ON auth.audit_log_entries USING btree (instance_id); - - --- --- Name: confirmation_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX confirmation_token_idx ON auth.users USING btree (confirmation_token) WHERE ((confirmation_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: email_change_token_current_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX email_change_token_current_idx ON auth.users USING btree (email_change_token_current) WHERE ((email_change_token_current)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: email_change_token_new_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX email_change_token_new_idx ON auth.users USING btree (email_change_token_new) WHERE ((email_change_token_new)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: factor_id_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX factor_id_created_at_idx ON auth.mfa_factors USING btree (user_id, created_at); - - --- --- Name: flow_state_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX flow_state_created_at_idx ON auth.flow_state USING btree (created_at DESC); - - --- --- Name: identities_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX identities_email_idx ON auth.identities USING btree (email text_pattern_ops); - - --- --- Name: INDEX identities_email_idx; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON INDEX auth.identities_email_idx IS 'Auth: Ensures indexed queries on the email column'; - - --- --- Name: identities_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX identities_user_id_idx ON auth.identities USING btree (user_id); - - --- --- Name: idx_auth_code; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX idx_auth_code ON auth.flow_state USING btree (auth_code); - - --- --- Name: idx_user_id_auth_method; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX idx_user_id_auth_method ON auth.flow_state USING btree (user_id, authentication_method); - - --- --- Name: mfa_challenge_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX mfa_challenge_created_at_idx ON auth.mfa_challenges USING btree (created_at DESC); - - --- --- Name: mfa_factors_user_friendly_name_unique; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX mfa_factors_user_friendly_name_unique ON auth.mfa_factors USING btree (friendly_name, user_id) WHERE (btrim(friendly_name) <> ''::text); - - --- --- Name: mfa_factors_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX mfa_factors_user_id_idx ON auth.mfa_factors USING btree (user_id); - - --- --- Name: reauthentication_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX reauthentication_token_idx ON auth.users USING btree (reauthentication_token) WHERE ((reauthentication_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: recovery_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX recovery_token_idx ON auth.users USING btree (recovery_token) WHERE ((recovery_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: refresh_tokens_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_instance_id_idx ON auth.refresh_tokens USING btree (instance_id); - - --- --- Name: refresh_tokens_instance_id_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_instance_id_user_id_idx ON auth.refresh_tokens USING btree (instance_id, user_id); - - --- --- Name: refresh_tokens_parent_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_parent_idx ON auth.refresh_tokens USING btree (parent); - - --- --- Name: refresh_tokens_session_id_revoked_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_session_id_revoked_idx ON auth.refresh_tokens USING btree (session_id, revoked); - - --- --- Name: refresh_tokens_updated_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_updated_at_idx ON auth.refresh_tokens USING btree (updated_at DESC); - - --- --- Name: saml_providers_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_providers_sso_provider_id_idx ON auth.saml_providers USING btree (sso_provider_id); - - --- --- Name: saml_relay_states_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_created_at_idx ON auth.saml_relay_states USING btree (created_at DESC); - - --- --- Name: saml_relay_states_for_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_for_email_idx ON auth.saml_relay_states USING btree (for_email); - - --- --- Name: saml_relay_states_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_sso_provider_id_idx ON auth.saml_relay_states USING btree (sso_provider_id); - - --- --- Name: sessions_not_after_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sessions_not_after_idx ON auth.sessions USING btree (not_after DESC); - - --- --- Name: sessions_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sessions_user_id_idx ON auth.sessions USING btree (user_id); - - --- --- Name: sso_domains_domain_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX sso_domains_domain_idx ON auth.sso_domains USING btree (lower(domain)); - - --- --- Name: sso_domains_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sso_domains_sso_provider_id_idx ON auth.sso_domains USING btree (sso_provider_id); - - --- --- Name: sso_providers_resource_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX sso_providers_resource_id_idx ON auth.sso_providers USING btree (lower(resource_id)); - - --- --- Name: user_id_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX user_id_created_at_idx ON auth.sessions USING btree (user_id, created_at); - - --- --- Name: users_email_partial_key; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX users_email_partial_key ON auth.users USING btree (email) WHERE (is_sso_user = false); - - --- --- Name: INDEX users_email_partial_key; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON INDEX auth.users_email_partial_key IS 'Auth: A partial unique index that applies only when is_sso_user is false'; - - --- --- Name: users_instance_id_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX users_instance_id_email_idx ON auth.users USING btree (instance_id, lower((email)::text)); - - --- --- Name: users_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX users_instance_id_idx ON auth.users USING btree (instance_id); - - --- --- Name: bname; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE UNIQUE INDEX bname ON storage.buckets USING btree (name); - - --- --- Name: bucketid_objname; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE UNIQUE INDEX bucketid_objname ON storage.objects USING btree (bucket_id, name); - - --- --- Name: name_prefix_search; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE INDEX name_prefix_search ON storage.objects USING btree (name text_pattern_ops); - - --- --- Name: objects update_objects_updated_at; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION storage.update_updated_at_column(); - - --- --- Name: extensions extensions_tenant_external_id_fkey; Type: FK CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.extensions - ADD CONSTRAINT extensions_tenant_external_id_fkey FOREIGN KEY (tenant_external_id) REFERENCES _realtime.tenants(external_id) ON DELETE CASCADE; - - --- --- Name: identities identities_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: mfa_amr_claims mfa_amr_claims_session_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_fkey FOREIGN KEY (session_id) REFERENCES auth.sessions(id) ON DELETE CASCADE; - - --- --- Name: mfa_challenges mfa_challenges_auth_factor_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_auth_factor_id_fkey FOREIGN KEY (factor_id) REFERENCES auth.mfa_factors(id) ON DELETE CASCADE; - - --- --- Name: mfa_factors mfa_factors_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_factors - ADD CONSTRAINT mfa_factors_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: refresh_tokens refresh_tokens_session_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_session_id_fkey FOREIGN KEY (session_id) REFERENCES auth.sessions(id) ON DELETE CASCADE; - - --- --- Name: saml_providers saml_providers_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: saml_relay_states saml_relay_states_flow_state_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_flow_state_id_fkey FOREIGN KEY (flow_state_id) REFERENCES auth.flow_state(id) ON DELETE CASCADE; - - --- --- Name: saml_relay_states saml_relay_states_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: sessions sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sessions - ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: sso_domains sso_domains_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_domains - ADD CONSTRAINT sso_domains_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: objects objects_bucketId_fkey; Type: FK CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.objects - ADD CONSTRAINT "objects_bucketId_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); - - --- --- Name: buckets; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY; - --- --- Name: migrations; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.migrations ENABLE ROW LEVEL SECURITY; - --- --- Name: objects; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; - --- --- Name: supabase_realtime; Type: PUBLICATION; Schema: -; Owner: postgres --- - -CREATE PUBLICATION supabase_realtime WITH (publish = 'insert, update, delete, truncate'); - - -ALTER PUBLICATION supabase_realtime OWNER TO postgres; - --- --- Name: SCHEMA auth; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA auth TO anon; -GRANT USAGE ON SCHEMA auth TO authenticated; -GRANT USAGE ON SCHEMA auth TO service_role; -GRANT ALL ON SCHEMA auth TO supabase_auth_admin; -GRANT ALL ON SCHEMA auth TO dashboard_user; -GRANT ALL ON SCHEMA auth TO postgres; - - --- --- Name: SCHEMA extensions; Type: ACL; Schema: -; Owner: postgres --- - -GRANT USAGE ON SCHEMA extensions TO anon; -GRANT USAGE ON SCHEMA extensions TO authenticated; -GRANT USAGE ON SCHEMA extensions TO service_role; -GRANT ALL ON SCHEMA extensions TO dashboard_user; - - --- --- Name: SCHEMA graphql_public; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA graphql_public TO postgres; -GRANT USAGE ON SCHEMA graphql_public TO anon; -GRANT USAGE ON SCHEMA graphql_public TO authenticated; -GRANT USAGE ON SCHEMA graphql_public TO service_role; - - --- --- Name: SCHEMA public; Type: ACL; Schema: -; Owner: postgres --- - -REVOKE USAGE ON SCHEMA public FROM PUBLIC; -GRANT ALL ON SCHEMA public TO PUBLIC; -GRANT USAGE ON SCHEMA public TO anon; -GRANT USAGE ON SCHEMA public TO authenticated; -GRANT USAGE ON SCHEMA public TO service_role; - - --- --- Name: SCHEMA realtime; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA realtime TO postgres; - - --- --- Name: SCHEMA storage; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT ALL ON SCHEMA storage TO postgres; -GRANT USAGE ON SCHEMA storage TO anon; -GRANT USAGE ON SCHEMA storage TO authenticated; -GRANT USAGE ON SCHEMA storage TO service_role; -GRANT ALL ON SCHEMA storage TO supabase_storage_admin; -GRANT ALL ON SCHEMA storage TO dashboard_user; - - --- --- Name: FUNCTION email(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.email() TO dashboard_user; - - --- --- Name: FUNCTION jwt(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.jwt() TO postgres; -GRANT ALL ON FUNCTION auth.jwt() TO dashboard_user; - - --- --- Name: FUNCTION role(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.role() TO dashboard_user; - - --- --- Name: FUNCTION uid(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.uid() TO dashboard_user; - - --- --- Name: FUNCTION algorithm_sign(signables text, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.algorithm_sign(signables text, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION armor(bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.armor(bytea) TO dashboard_user; - - --- --- Name: FUNCTION armor(bytea, text[], text[]); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.armor(bytea, text[], text[]) TO dashboard_user; - - --- --- Name: FUNCTION crypt(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.crypt(text, text) TO dashboard_user; - - --- --- Name: FUNCTION dearmor(text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.dearmor(text) TO dashboard_user; - - --- --- Name: FUNCTION decrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.decrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION decrypt_iv(bytea, bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.decrypt_iv(bytea, bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION digest(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.digest(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION digest(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.digest(text, text) TO dashboard_user; - - --- --- Name: FUNCTION encrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.encrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION encrypt_iv(bytea, bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.encrypt_iv(bytea, bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION gen_random_bytes(integer); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_random_bytes(integer) TO dashboard_user; - - --- --- Name: FUNCTION gen_random_uuid(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_random_uuid() TO dashboard_user; - - --- --- Name: FUNCTION gen_salt(text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_salt(text) TO dashboard_user; - - --- --- Name: FUNCTION gen_salt(text, integer); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_salt(text, integer) TO dashboard_user; - - --- --- Name: FUNCTION grant_pg_cron_access(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.grant_pg_cron_access() TO dashboard_user; - - --- --- Name: FUNCTION grant_pg_net_access(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.grant_pg_net_access() TO dashboard_user; - - --- --- Name: FUNCTION hmac(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.hmac(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION hmac(text, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.hmac(text, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_armor_headers(text, OUT key text, OUT value text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_armor_headers(text, OUT key text, OUT value text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_key_id(bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_key_id(bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt(text, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt(text, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt(text, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt(text, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt_bytea(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt_bytea(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt_bytea(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt_bytea(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt(text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt(text, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt(text, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt_bytea(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt_bytea(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION sign(payload json, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.sign(payload json, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION url_decode(data text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.url_decode(data text) TO dashboard_user; - - --- --- Name: FUNCTION url_encode(data bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.url_encode(data bytea) TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v1(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v1() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v1mc(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v1mc() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v3(namespace uuid, name text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v3(namespace uuid, name text) TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v4(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v4() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v5(namespace uuid, name text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v5(namespace uuid, name text) TO dashboard_user; - - --- --- Name: FUNCTION uuid_nil(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_nil() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_dns(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_dns() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_oid(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_oid() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_url(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_url() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_x500(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_x500() TO dashboard_user; - - --- --- Name: FUNCTION verify(token text, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.verify(token text, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION extension(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.extension(name text) TO anon; -GRANT ALL ON FUNCTION storage.extension(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.extension(name text) TO service_role; -GRANT ALL ON FUNCTION storage.extension(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.extension(name text) TO postgres; - - --- --- Name: FUNCTION filename(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.filename(name text) TO anon; -GRANT ALL ON FUNCTION storage.filename(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.filename(name text) TO service_role; -GRANT ALL ON FUNCTION storage.filename(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.filename(name text) TO postgres; - - --- --- Name: FUNCTION foldername(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.foldername(name text) TO anon; -GRANT ALL ON FUNCTION storage.foldername(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.foldername(name text) TO service_role; -GRANT ALL ON FUNCTION storage.foldername(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.foldername(name text) TO postgres; - - --- --- Name: TABLE audit_log_entries; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.audit_log_entries TO dashboard_user; -GRANT ALL ON TABLE auth.audit_log_entries TO postgres; - - --- --- Name: TABLE flow_state; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.flow_state TO postgres; -GRANT ALL ON TABLE auth.flow_state TO dashboard_user; - - --- --- Name: TABLE identities; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.identities TO postgres; -GRANT ALL ON TABLE auth.identities TO dashboard_user; - - --- --- Name: TABLE instances; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.instances TO dashboard_user; -GRANT ALL ON TABLE auth.instances TO postgres; - - --- --- Name: TABLE mfa_amr_claims; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_amr_claims TO postgres; -GRANT ALL ON TABLE auth.mfa_amr_claims TO dashboard_user; - - --- --- Name: TABLE mfa_challenges; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_challenges TO postgres; -GRANT ALL ON TABLE auth.mfa_challenges TO dashboard_user; - - --- --- Name: TABLE mfa_factors; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_factors TO postgres; -GRANT ALL ON TABLE auth.mfa_factors TO dashboard_user; - - --- --- Name: TABLE refresh_tokens; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.refresh_tokens TO dashboard_user; -GRANT ALL ON TABLE auth.refresh_tokens TO postgres; - - --- --- Name: SEQUENCE refresh_tokens_id_seq; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON SEQUENCE auth.refresh_tokens_id_seq TO dashboard_user; -GRANT ALL ON SEQUENCE auth.refresh_tokens_id_seq TO postgres; - - --- --- Name: TABLE saml_providers; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.saml_providers TO postgres; -GRANT ALL ON TABLE auth.saml_providers TO dashboard_user; - - --- --- Name: TABLE saml_relay_states; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.saml_relay_states TO postgres; -GRANT ALL ON TABLE auth.saml_relay_states TO dashboard_user; - - --- --- Name: TABLE schema_migrations; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.schema_migrations TO dashboard_user; -GRANT ALL ON TABLE auth.schema_migrations TO postgres; - - --- --- Name: TABLE sessions; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sessions TO postgres; -GRANT ALL ON TABLE auth.sessions TO dashboard_user; - - --- --- Name: TABLE sso_domains; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sso_domains TO postgres; -GRANT ALL ON TABLE auth.sso_domains TO dashboard_user; - - --- --- Name: TABLE sso_providers; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sso_providers TO postgres; -GRANT ALL ON TABLE auth.sso_providers TO dashboard_user; - - --- --- Name: TABLE users; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.users TO dashboard_user; -GRANT ALL ON TABLE auth.users TO postgres; - - --- --- Name: TABLE buckets; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.buckets TO anon; -GRANT ALL ON TABLE storage.buckets TO authenticated; -GRANT ALL ON TABLE storage.buckets TO service_role; -GRANT ALL ON TABLE storage.buckets TO postgres; - - --- --- Name: TABLE migrations; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.migrations TO anon; -GRANT ALL ON TABLE storage.migrations TO authenticated; -GRANT ALL ON TABLE storage.migrations TO service_role; -GRANT ALL ON TABLE storage.migrations TO postgres; - - --- --- Name: TABLE objects; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.objects TO anon; -GRANT ALL ON TABLE storage.objects TO authenticated; -GRANT ALL ON TABLE storage.objects TO service_role; -GRANT ALL ON TABLE storage.objects TO postgres; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON SEQUENCES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON FUNCTIONS TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON TABLES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON SEQUENCES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON FUNCTIONS TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON TABLES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO service_role; - - --- --- Name: issue_graphql_placeholder; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_graphql_placeholder ON sql_drop - WHEN TAG IN ('DROP EXTENSION') - EXECUTE FUNCTION extensions.set_graphql_placeholder(); - - -ALTER EVENT TRIGGER issue_graphql_placeholder OWNER TO supabase_admin; - --- --- Name: issue_pg_cron_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_cron_access ON ddl_command_end - WHEN TAG IN ('CREATE SCHEMA') - EXECUTE FUNCTION extensions.grant_pg_cron_access(); - - -ALTER EVENT TRIGGER issue_pg_cron_access OWNER TO supabase_admin; - --- --- Name: issue_pg_graphql_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_graphql_access ON ddl_command_end - WHEN TAG IN ('CREATE FUNCTION') - EXECUTE FUNCTION extensions.grant_pg_graphql_access(); - - -ALTER EVENT TRIGGER issue_pg_graphql_access OWNER TO supabase_admin; - --- --- Name: issue_pg_net_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end - WHEN TAG IN ('CREATE EXTENSION') - EXECUTE FUNCTION extensions.grant_pg_net_access(); - - -ALTER EVENT TRIGGER issue_pg_net_access OWNER TO supabase_admin; - --- --- Name: pgrst_ddl_watch; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER pgrst_ddl_watch ON ddl_command_end - EXECUTE FUNCTION extensions.pgrst_ddl_watch(); - - -ALTER EVENT TRIGGER pgrst_ddl_watch OWNER TO supabase_admin; - --- --- Name: pgrst_drop_watch; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop - EXECUTE FUNCTION extensions.pgrst_drop_watch(); - - -ALTER EVENT TRIGGER pgrst_drop_watch OWNER TO supabase_admin; - --- --- PostgreSQL database dump complete --- - -RESET ALL; diff --git a/apps/cli-go/internal/utils/templates/initial_schemas/14.sql b/apps/cli-go/internal/utils/templates/initial_schemas/14.sql deleted file mode 100644 index bef44153ec..0000000000 --- a/apps/cli-go/internal/utils/templates/initial_schemas/14.sql +++ /dev/null @@ -1,3690 +0,0 @@ --- --- PostgreSQL database dump --- - --- Dumped from database version 14.5 (Debian 14.5-2.pgdg110+2) --- Dumped by pg_dump version 15.3 - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Name: _analytics; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS _analytics; - - -ALTER SCHEMA _analytics OWNER TO postgres; - --- --- Name: _realtime; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS _realtime; - - -ALTER SCHEMA _realtime OWNER TO postgres; - --- --- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS auth; - - -ALTER SCHEMA auth OWNER TO supabase_admin; - --- --- Name: extensions; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS extensions; - - -ALTER SCHEMA extensions OWNER TO postgres; - --- --- Name: graphql; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS graphql; - - -ALTER SCHEMA graphql OWNER TO supabase_admin; - --- --- Name: graphql_public; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS graphql_public; - - -ALTER SCHEMA graphql_public OWNER TO supabase_admin; - --- --- Name: pg_net; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; - - --- --- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - - --- --- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer --- - -CREATE SCHEMA IF NOT EXISTS pgbouncer; - - -ALTER SCHEMA pgbouncer OWNER TO pgbouncer; - --- --- Name: pgsodium; Type: SCHEMA; Schema: -; Owner: postgres --- - -CREATE SCHEMA IF NOT EXISTS pgsodium; - - -ALTER SCHEMA pgsodium OWNER TO postgres; - --- --- Name: pgsodium; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgsodium WITH SCHEMA pgsodium; - - --- --- Name: EXTENSION pgsodium; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgsodium IS 'Pgsodium is a modern cryptography library for Postgres.'; - - --- --- Name: public; Type: SCHEMA; Schema: -; Owner: postgres --- - --- *not* creating schema, since initdb creates it - - -ALTER SCHEMA public OWNER TO postgres; - --- --- Name: realtime; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS realtime; - - -ALTER SCHEMA realtime OWNER TO supabase_admin; - --- --- Name: storage; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS storage; - - -ALTER SCHEMA storage OWNER TO supabase_admin; - --- --- Name: supabase_functions; Type: SCHEMA; Schema: -; Owner: supabase_admin --- - -CREATE SCHEMA IF NOT EXISTS supabase_functions; - - -ALTER SCHEMA supabase_functions OWNER TO supabase_admin; - --- --- Name: pg_graphql; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_graphql WITH SCHEMA graphql; - - --- --- Name: EXTENSION pg_graphql; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_graphql IS 'pg_graphql: GraphQL support'; - - --- --- Name: pg_stat_statements; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions; - - --- --- Name: EXTENSION pg_stat_statements; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_stat_statements IS 'track planning and execution statistics of all SQL statements executed'; - - --- --- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; - - --- --- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - - --- --- Name: pgjwt; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions; - - --- --- Name: EXTENSION pgjwt; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pgjwt IS 'JSON Web Token API for Postgresql'; - - --- --- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; - - --- --- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; - - --- --- Name: aal_level; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.aal_level AS ENUM ( - 'aal1', - 'aal2', - 'aal3' -); - - -ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin; - --- --- Name: code_challenge_method; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.code_challenge_method AS ENUM ( - 's256', - 'plain' -); - - -ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin; - --- --- Name: factor_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.factor_status AS ENUM ( - 'unverified', - 'verified' -); - - -ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin; - --- --- Name: factor_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TYPE auth.factor_type AS ENUM ( - 'totp', - 'webauthn' -); - - -ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin; - --- --- Name: email(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.email() RETURNS text - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.email', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') - )::text -$$; - - -ALTER FUNCTION auth.email() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION email(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.'; - - --- --- Name: jwt(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb -$$; - - -ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; - --- --- Name: role(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.role() RETURNS text - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.role', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') - )::text -$$; - - -ALTER FUNCTION auth.role() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION role(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.'; - - --- --- Name: uid(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin --- - -CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid - LANGUAGE sql STABLE - AS $$ - select - coalesce( - nullif(current_setting('request.jwt.claim.sub', true), ''), - (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') - )::uuid -$$; - - -ALTER FUNCTION auth.uid() OWNER TO supabase_auth_admin; - --- --- Name: FUNCTION uid(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; - - --- --- Name: grant_pg_cron_access(); Type: FUNCTION; Schema: extensions; Owner: postgres --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - schema_is_cron bool; -BEGIN - schema_is_cron = ( - SELECT n.nspname = 'cron' - FROM pg_event_trigger_ddl_commands() AS ev - LEFT JOIN pg_catalog.pg_namespace AS n - ON ev.objid = n.oid - ); - - IF schema_is_cron - THEN - grant usage on schema cron to postgres with grant option; - - alter default privileges in schema cron grant all on tables to postgres with grant option; - alter default privileges in schema cron grant all on functions to postgres with grant option; - alter default privileges in schema cron grant all on sequences to postgres with grant option; - - alter default privileges for user supabase_admin in schema cron grant all - on sequences to postgres with grant option; - alter default privileges for user supabase_admin in schema cron grant all - on tables to postgres with grant option; - alter default privileges for user supabase_admin in schema cron grant all - on functions to postgres with grant option; - - grant all privileges on all tables in schema cron to postgres with grant option; - - END IF; - -END; -$$; - - -ALTER FUNCTION extensions.grant_pg_cron_access() OWNER TO postgres; - --- --- Name: FUNCTION grant_pg_cron_access(); Type: COMMENT; Schema: extensions; Owner: postgres --- - -COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cron'; - - --- --- Name: grant_pg_graphql_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $_$ -DECLARE - func_is_graphql_resolve bool; -BEGIN - func_is_graphql_resolve = ( - SELECT n.proname = 'resolve' - FROM pg_event_trigger_ddl_commands() AS ev - LEFT JOIN pg_catalog.pg_proc AS n - ON ev.objid = n.oid - ); - - IF func_is_graphql_resolve - THEN - -- Update public wrapper to pass all arguments through to the pg_graphql resolve func - DROP FUNCTION IF EXISTS graphql_public.graphql; - create or replace function graphql_public.graphql( - "operationName" text default null, - query text default null, - variables jsonb default null, - extensions jsonb default null - ) - returns jsonb - language sql - as $$ - select graphql.resolve( - query := query, - variables := coalesce(variables, '{}'), - "operationName" := "operationName", - extensions := extensions - ); - $$; - - -- This hook executes when `graphql.resolve` is created. That is not necessarily the last - -- function in the extension so we need to grant permissions on existing entities AND - -- update default permissions to any others that are created after `graphql.resolve` - grant usage on schema graphql to postgres, anon, authenticated, service_role; - grant select on all tables in schema graphql to postgres, anon, authenticated, service_role; - grant execute on all functions in schema graphql to postgres, anon, authenticated, service_role; - grant all on all sequences in schema graphql to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role; - alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role; - END IF; - -END; -$_$; - - -ALTER FUNCTION extensions.grant_pg_graphql_access() OWNER TO supabase_admin; - --- --- Name: FUNCTION grant_pg_graphql_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin --- - -COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg_graphql'; - - --- --- Name: grant_pg_net_access(); Type: FUNCTION; Schema: extensions; Owner: postgres --- - -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$$; - - -ALTER FUNCTION extensions.grant_pg_net_access() OWNER TO postgres; - --- --- Name: FUNCTION grant_pg_net_access(); Type: COMMENT; Schema: extensions; Owner: postgres --- - -COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net'; - - --- --- Name: pgrst_ddl_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - cmd record; -BEGIN - FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() - LOOP - IF cmd.command_tag IN ( - 'CREATE SCHEMA', 'ALTER SCHEMA' - , 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE' - , 'CREATE FOREIGN TABLE', 'ALTER FOREIGN TABLE' - , 'CREATE VIEW', 'ALTER VIEW' - , 'CREATE MATERIALIZED VIEW', 'ALTER MATERIALIZED VIEW' - , 'CREATE FUNCTION', 'ALTER FUNCTION' - , 'CREATE TRIGGER' - , 'CREATE TYPE', 'ALTER TYPE' - , 'CREATE RULE' - , 'COMMENT' - ) - -- don't notify in case of CREATE TEMP table or other objects created on pg_temp - AND cmd.schema_name is distinct from 'pg_temp' - THEN - NOTIFY pgrst, 'reload schema'; - END IF; - END LOOP; -END; $$; - - -ALTER FUNCTION extensions.pgrst_ddl_watch() OWNER TO supabase_admin; - --- --- Name: pgrst_drop_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger - LANGUAGE plpgsql - AS $$ -DECLARE - obj record; -BEGIN - FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() - LOOP - IF obj.object_type IN ( - 'schema' - , 'table' - , 'foreign table' - , 'view' - , 'materialized view' - , 'function' - , 'trigger' - , 'type' - , 'rule' - ) - AND obj.is_temporary IS false -- no pg_temp objects - THEN - NOTIFY pgrst, 'reload schema'; - END IF; - END LOOP; -END; $$; - - -ALTER FUNCTION extensions.pgrst_drop_watch() OWNER TO supabase_admin; - --- --- Name: set_graphql_placeholder(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin --- - -CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger - LANGUAGE plpgsql - AS $_$ - DECLARE - graphql_is_dropped bool; - BEGIN - graphql_is_dropped = ( - SELECT ev.schema_name = 'graphql_public' - FROM pg_event_trigger_dropped_objects() AS ev - WHERE ev.schema_name = 'graphql_public' - ); - - IF graphql_is_dropped - THEN - create or replace function graphql_public.graphql( - "operationName" text default null, - query text default null, - variables jsonb default null, - extensions jsonb default null - ) - returns jsonb - language plpgsql - as $$ - DECLARE - server_version float; - BEGIN - server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); - - IF server_version >= 14 THEN - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql extension is not enabled.' - ) - ) - ); - ELSE - RETURN jsonb_build_object( - 'errors', jsonb_build_array( - jsonb_build_object( - 'message', 'pg_graphql is only available on projects running Postgres 14 onwards.' - ) - ) - ); - END IF; - END; - $$; - END IF; - - END; -$_$; - - -ALTER FUNCTION extensions.set_graphql_placeholder() OWNER TO supabase_admin; - --- --- Name: FUNCTION set_graphql_placeholder(); Type: COMMENT; Schema: extensions; Owner: supabase_admin --- - -COMMENT ON FUNCTION extensions.set_graphql_placeholder() IS 'Reintroduces placeholder function for graphql_public.graphql'; - - --- --- Name: get_auth(text); Type: FUNCTION; Schema: pgbouncer; Owner: postgres --- - -CREATE OR REPLACE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text) - LANGUAGE plpgsql SECURITY DEFINER - AS $$ -BEGIN - RAISE WARNING 'PgBouncer auth request: %', p_usename; - - RETURN QUERY - SELECT usename::TEXT, passwd::TEXT FROM pg_catalog.pg_shadow - WHERE usename = p_usename; -END; -$$; - - -ALTER FUNCTION pgbouncer.get_auth(p_usename text) OWNER TO postgres; - --- --- Name: can_insert_object(text, text, uuid, jsonb); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void - LANGUAGE plpgsql - AS $$ -BEGIN - INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata); - -- hack to rollback the successful insert - RAISE sqlstate 'PT200' using - message = 'ROLLBACK', - detail = 'rollback successful insert'; -END -$$; - - -ALTER FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) OWNER TO supabase_storage_admin; - --- --- Name: extension(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.extension(name text) RETURNS text - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -_filename text; -BEGIN - select string_to_array(name, '/') into _parts; - select _parts[array_length(_parts,1)] into _filename; - -- @todo return the last part instead of 2 - return split_part(_filename, '.', 2); -END -$$; - - -ALTER FUNCTION storage.extension(name text) OWNER TO supabase_storage_admin; - --- --- Name: filename(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.filename(name text) RETURNS text - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[array_length(_parts,1)]; -END -$$; - - -ALTER FUNCTION storage.filename(name text) OWNER TO supabase_storage_admin; - --- --- Name: foldername(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.foldername(name text) RETURNS text[] - LANGUAGE plpgsql - AS $$ -DECLARE -_parts text[]; -BEGIN - select string_to_array(name, '/') into _parts; - return _parts[1:array_length(_parts,1)-1]; -END -$$; - - -ALTER FUNCTION storage.foldername(name text) OWNER TO supabase_storage_admin; - --- --- Name: get_size_by_bucket(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text) - LANGUAGE plpgsql - AS $$ -BEGIN - return query - select sum((metadata->>'size')::int) as size, obj.bucket_id - from "storage".objects as obj - group by obj.bucket_id; -END -$$; - - -ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin; - --- --- Name: search(text, text, integer, integer, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb) - LANGUAGE plpgsql STABLE - AS $_$ -declare - v_order_by text; - v_sort_order text; -begin - case - when sortcolumn = 'name' then - v_order_by = 'name'; - when sortcolumn = 'updated_at' then - v_order_by = 'updated_at'; - when sortcolumn = 'created_at' then - v_order_by = 'created_at'; - when sortcolumn = 'last_accessed_at' then - v_order_by = 'last_accessed_at'; - else - v_order_by = 'name'; - end case; - - case - when sortorder = 'asc' then - v_sort_order = 'asc'; - when sortorder = 'desc' then - v_sort_order = 'desc'; - else - v_sort_order = 'asc'; - end case; - - v_order_by = v_order_by || ' ' || v_sort_order; - - return query execute - 'with folders as ( - select path_tokens[$1] as folder - from storage.objects - where objects.name ilike $2 || $3 || ''%'' - and bucket_id = $4 - and array_length(regexp_split_to_array(objects.name, ''/''), 1) <> $1 - group by folder - order by folder ' || v_sort_order || ' - ) - (select folder as "name", - null as id, - null as updated_at, - null as created_at, - null as last_accessed_at, - null as metadata from folders) - union all - (select path_tokens[$1] as "name", - id, - updated_at, - created_at, - last_accessed_at, - metadata - from storage.objects - where objects.name ilike $2 || $3 || ''%'' - and bucket_id = $4 - and array_length(regexp_split_to_array(objects.name, ''/''), 1) = $1 - order by ' || v_order_by || ') - limit $5 - offset $6' using levels, prefix, search, bucketname, limits, offsets; -end; -$_$; - - -ALTER FUNCTION storage.search(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text) OWNER TO supabase_storage_admin; - --- --- Name: update_updated_at_column(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin --- - -CREATE OR REPLACE FUNCTION storage.update_updated_at_column() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$; - - -ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin; - --- --- Name: http_request(); Type: FUNCTION; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE OR REPLACE FUNCTION supabase_functions.http_request() RETURNS trigger - LANGUAGE plpgsql SECURITY DEFINER - SET search_path TO 'supabase_functions' - AS $$ - DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; - BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - - RETURN NEW; - END -$$; - - -ALTER FUNCTION supabase_functions.http_request() OWNER TO supabase_functions_admin; - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: extensions; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.extensions ( - id uuid NOT NULL, - type text, - settings jsonb, - tenant_external_id text, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL -); - - -ALTER TABLE _realtime.extensions OWNER TO postgres; - --- --- Name: schema_migrations; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.schema_migrations ( - version bigint NOT NULL, - inserted_at timestamp(0) without time zone -); - - -ALTER TABLE _realtime.schema_migrations OWNER TO postgres; - --- --- Name: tenants; Type: TABLE; Schema: _realtime; Owner: postgres --- - -CREATE TABLE IF NOT EXISTS _realtime.tenants ( - id uuid NOT NULL, - name text, - external_id text, - jwt_secret text, - max_concurrent_users integer DEFAULT 200 NOT NULL, - inserted_at timestamp(0) without time zone NOT NULL, - updated_at timestamp(0) without time zone NOT NULL, - max_events_per_second integer DEFAULT 100 NOT NULL, - postgres_cdc_default text DEFAULT 'postgres_cdc_rls'::text, - max_bytes_per_second integer DEFAULT 100000 NOT NULL, - max_channels_per_client integer DEFAULT 100 NOT NULL, - max_joins_per_second integer DEFAULT 500 NOT NULL, - suspend boolean DEFAULT false -); - - -ALTER TABLE _realtime.tenants OWNER TO postgres; - --- --- Name: audit_log_entries; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.audit_log_entries ( - instance_id uuid, - id uuid NOT NULL, - payload json, - created_at timestamp with time zone, - ip_address character varying(64) DEFAULT ''::character varying NOT NULL -); - - -ALTER TABLE auth.audit_log_entries OWNER TO supabase_auth_admin; - --- --- Name: TABLE audit_log_entries; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.audit_log_entries IS 'Auth: Audit trail for user actions.'; - - --- --- Name: flow_state; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.flow_state ( - id uuid NOT NULL, - user_id uuid, - auth_code text NOT NULL, - code_challenge_method auth.code_challenge_method NOT NULL, - code_challenge text NOT NULL, - provider_type text NOT NULL, - provider_access_token text, - provider_refresh_token text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - authentication_method text NOT NULL -); - - -ALTER TABLE auth.flow_state OWNER TO supabase_auth_admin; - --- --- Name: TABLE flow_state; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.flow_state IS 'stores metadata for pkce logins'; - - --- --- Name: identities; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.identities ( - provider_id text NOT NULL, - user_id uuid NOT NULL, - identity_data jsonb NOT NULL, - provider text NOT NULL, - last_sign_in_at timestamp with time zone, - created_at timestamp with time zone, - updated_at timestamp with time zone, - email text GENERATED ALWAYS AS (lower((identity_data ->> 'email'::text))) STORED, - id uuid DEFAULT gen_random_uuid() NOT NULL -); - - -ALTER TABLE auth.identities OWNER TO supabase_auth_admin; - --- --- Name: TABLE identities; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.'; - - --- --- Name: COLUMN identities.email; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data'; - - --- --- Name: instances; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.instances ( - id uuid NOT NULL, - uuid uuid, - raw_base_config text, - created_at timestamp with time zone, - updated_at timestamp with time zone -); - - -ALTER TABLE auth.instances OWNER TO supabase_auth_admin; - --- --- Name: TABLE instances; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.instances IS 'Auth: Manages users across multiple sites.'; - - --- --- Name: mfa_amr_claims; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_amr_claims ( - session_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - authentication_method text NOT NULL, - id uuid NOT NULL -); - - -ALTER TABLE auth.mfa_amr_claims OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_amr_claims; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication'; - - --- --- Name: mfa_challenges; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_challenges ( - id uuid NOT NULL, - factor_id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - verified_at timestamp with time zone, - ip_address inet NOT NULL -); - - -ALTER TABLE auth.mfa_challenges OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_challenges; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made'; - - --- --- Name: mfa_factors; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.mfa_factors ( - id uuid NOT NULL, - user_id uuid NOT NULL, - friendly_name text, - factor_type auth.factor_type NOT NULL, - status auth.factor_status NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - secret text -); - - -ALTER TABLE auth.mfa_factors OWNER TO supabase_auth_admin; - --- --- Name: TABLE mfa_factors; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors'; - - --- --- Name: refresh_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.refresh_tokens ( - instance_id uuid, - id bigint NOT NULL, - token character varying(255), - user_id character varying(255), - revoked boolean, - created_at timestamp with time zone, - updated_at timestamp with time zone, - parent character varying(255), - session_id uuid -); - - -ALTER TABLE auth.refresh_tokens OWNER TO supabase_auth_admin; - --- --- Name: TABLE refresh_tokens; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.refresh_tokens IS 'Auth: Store of tokens used to refresh JWT tokens once they expire.'; - - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE SEQUENCE IF NOT EXISTS auth.refresh_tokens_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE auth.refresh_tokens_id_seq OWNER TO supabase_auth_admin; - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: auth; Owner: supabase_auth_admin --- - -ALTER SEQUENCE auth.refresh_tokens_id_seq OWNED BY auth.refresh_tokens.id; - - --- --- Name: saml_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.saml_providers ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - entity_id text NOT NULL, - metadata_xml text NOT NULL, - metadata_url text, - attribute_mapping jsonb, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "entity_id not empty" CHECK ((char_length(entity_id) > 0)), - CONSTRAINT "metadata_url not empty" CHECK (((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))), - CONSTRAINT "metadata_xml not empty" CHECK ((char_length(metadata_xml) > 0)) -); - - -ALTER TABLE auth.saml_providers OWNER TO supabase_auth_admin; - --- --- Name: TABLE saml_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.'; - - --- --- Name: saml_relay_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.saml_relay_states ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - request_id text NOT NULL, - for_email text, - redirect_to text, - from_ip_address inet, - created_at timestamp with time zone, - updated_at timestamp with time zone, - flow_state_id uuid, - CONSTRAINT "request_id not empty" CHECK ((char_length(request_id) > 0)) -); - - -ALTER TABLE auth.saml_relay_states OWNER TO supabase_auth_admin; - --- --- Name: TABLE saml_relay_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.'; - - --- --- Name: schema_migrations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.schema_migrations ( - version character varying(255) NOT NULL -); - - -ALTER TABLE auth.schema_migrations OWNER TO supabase_auth_admin; - --- --- Name: TABLE schema_migrations; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.schema_migrations IS 'Auth: Manages updates to the auth system.'; - - --- --- Name: sessions; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sessions ( - id uuid NOT NULL, - user_id uuid NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - factor_id uuid, - aal auth.aal_level, - not_after timestamp with time zone, - refreshed_at timestamp without time zone, - user_agent text, - ip inet, - tag text -); - - -ALTER TABLE auth.sessions OWNER TO supabase_auth_admin; - --- --- Name: TABLE sessions; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.'; - - --- --- Name: COLUMN sessions.not_after; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.'; - - --- --- Name: sso_domains; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sso_domains ( - id uuid NOT NULL, - sso_provider_id uuid NOT NULL, - domain text NOT NULL, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "domain not empty" CHECK ((char_length(domain) > 0)) -); - - -ALTER TABLE auth.sso_domains OWNER TO supabase_auth_admin; - --- --- Name: TABLE sso_domains; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.'; - - --- --- Name: sso_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.sso_providers ( - id uuid NOT NULL, - resource_id text, - created_at timestamp with time zone, - updated_at timestamp with time zone, - CONSTRAINT "resource_id not empty" CHECK (((resource_id = NULL::text) OR (char_length(resource_id) > 0))) -); - - -ALTER TABLE auth.sso_providers OWNER TO supabase_auth_admin; - --- --- Name: TABLE sso_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.'; - - --- --- Name: COLUMN sso_providers.resource_id; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.'; - - --- --- Name: users; Type: TABLE; Schema: auth; Owner: supabase_auth_admin --- - -CREATE TABLE IF NOT EXISTS auth.users ( - instance_id uuid, - id uuid NOT NULL, - aud character varying(255), - role character varying(255), - email character varying(255), - encrypted_password character varying(255), - email_confirmed_at timestamp with time zone, - invited_at timestamp with time zone, - confirmation_token character varying(255), - confirmation_sent_at timestamp with time zone, - recovery_token character varying(255), - recovery_sent_at timestamp with time zone, - email_change_token_new character varying(255), - email_change character varying(255), - email_change_sent_at timestamp with time zone, - last_sign_in_at timestamp with time zone, - raw_app_meta_data jsonb, - raw_user_meta_data jsonb, - is_super_admin boolean, - created_at timestamp with time zone, - updated_at timestamp with time zone, - phone text DEFAULT NULL::character varying, - phone_confirmed_at timestamp with time zone, - phone_change text DEFAULT ''::character varying, - phone_change_token character varying(255) DEFAULT ''::character varying, - phone_change_sent_at timestamp with time zone, - confirmed_at timestamp with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED, - email_change_token_current character varying(255) DEFAULT ''::character varying, - email_change_confirm_status smallint DEFAULT 0, - banned_until timestamp with time zone, - reauthentication_token character varying(255) DEFAULT ''::character varying, - reauthentication_sent_at timestamp with time zone, - is_sso_user boolean DEFAULT false NOT NULL, - deleted_at timestamp with time zone, - CONSTRAINT users_email_change_confirm_status_check CHECK (((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))) -); - - -ALTER TABLE auth.users OWNER TO supabase_auth_admin; - --- --- Name: TABLE users; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON TABLE auth.users IS 'Auth: Stores user login data within a secure schema.'; - - --- --- Name: COLUMN users.is_sso_user; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.'; - - --- --- Name: buckets; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.buckets ( - id text NOT NULL, - name text NOT NULL, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - public boolean DEFAULT false, - avif_autodetection boolean DEFAULT false, - file_size_limit bigint, - allowed_mime_types text[], - owner_id text -); - - -ALTER TABLE storage.buckets OWNER TO supabase_storage_admin; - --- --- Name: COLUMN buckets.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin --- - -COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead'; - - --- --- Name: migrations; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.migrations ( - id integer NOT NULL, - name character varying(100) NOT NULL, - hash character varying(40) NOT NULL, - executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP -); - - -ALTER TABLE storage.migrations OWNER TO supabase_storage_admin; - --- --- Name: objects; Type: TABLE; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TABLE IF NOT EXISTS storage.objects ( - id uuid DEFAULT extensions.uuid_generate_v4() NOT NULL, - bucket_id text, - name text, - owner uuid, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now(), - last_accessed_at timestamp with time zone DEFAULT now(), - metadata jsonb, - path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) STORED, - version text, - owner_id text -); - - -ALTER TABLE storage.objects OWNER TO supabase_storage_admin; - --- --- Name: COLUMN objects.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin --- - -COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead'; - - --- --- Name: hooks; Type: TABLE; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE TABLE IF NOT EXISTS supabase_functions.hooks ( - id bigint NOT NULL, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - request_id bigint -); - - -ALTER TABLE supabase_functions.hooks OWNER TO supabase_functions_admin; - --- --- Name: TABLE hooks; Type: COMMENT; Schema: supabase_functions; Owner: supabase_functions_admin --- - -COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; - - --- --- Name: hooks_id_seq; Type: SEQUENCE; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE SEQUENCE IF NOT EXISTS supabase_functions.hooks_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE supabase_functions.hooks_id_seq OWNER TO supabase_functions_admin; - --- --- Name: hooks_id_seq; Type: SEQUENCE OWNED BY; Schema: supabase_functions; Owner: supabase_functions_admin --- - -ALTER SEQUENCE supabase_functions.hooks_id_seq OWNED BY supabase_functions.hooks.id; - - --- --- Name: migrations; Type: TABLE; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE TABLE IF NOT EXISTS supabase_functions.migrations ( - version text NOT NULL, - inserted_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE supabase_functions.migrations OWNER TO supabase_functions_admin; - --- --- Name: refresh_tokens id; Type: DEFAULT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens ALTER COLUMN id SET DEFAULT nextval('auth.refresh_tokens_id_seq'::regclass); - - --- --- Name: hooks id; Type: DEFAULT; Schema: supabase_functions; Owner: supabase_functions_admin --- - -ALTER TABLE ONLY supabase_functions.hooks ALTER COLUMN id SET DEFAULT nextval('supabase_functions.hooks_id_seq'::regclass); - - --- --- Data for Name: extensions; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.extensions (id, type, settings, tenant_external_id, inserted_at, updated_at) VALUES ('832b3aeb-9d7f-41aa-ad31-60ea054c294f', 'postgres_cdc_rls', '{"region": "us-east-1", "db_host": "ABK7kBu27y/PVdL10i/b+A==", "db_name": "sWBpZNdjggEPTQVlI52Zfw==", "db_port": "+enMDFi1J/3IrrquHHwUmA==", "db_user": "uxbEq/zz8DXVD53TOI1zmw==", "slot_name": "supabase_realtime_replication_slot", "ip_version": 4, "db_password": "sWBpZNdjggEPTQVlI52Zfw==", "publication": "supabase_realtime", "ssl_enforced": false, "poll_interval_ms": 100, "poll_max_changes": 100, "poll_max_record_bytes": 1048576}', 'realtime-dev', '2023-11-20 22:04:38', '2023-11-20 22:04:38'); - - --- --- Data for Name: schema_migrations; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20210706140551, '2023-03-16 02:25:35'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220329161857, '2023-03-16 02:25:35'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220410212326, '2023-03-16 02:25:35'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220506102948, '2023-03-16 02:25:35'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220527210857, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220815211129, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220815215024, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20220818141501, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221018173709, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221102172703, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20221223010058, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230110180046, '2023-03-16 02:25:36'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230810220907, '2023-11-20 21:54:23'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20230810220924, '2023-11-20 21:54:23'); -INSERT INTO _realtime.schema_migrations (version, inserted_at) VALUES (20231024094642, '2023-11-20 21:54:23'); - - --- --- Data for Name: tenants; Type: TABLE DATA; Schema: _realtime; Owner: postgres --- - -INSERT INTO _realtime.tenants (id, name, external_id, jwt_secret, max_concurrent_users, inserted_at, updated_at, max_events_per_second, postgres_cdc_default, max_bytes_per_second, max_channels_per_client, max_joins_per_second, suspend) VALUES ('f418662c-07b5-4160-9338-4db7fbaaabbd', 'realtime-dev', 'realtime-dev', 'iNjicxc4+llvc9wovDvqymwfnj9teWMlyOIbJ8Fh6j2WNU8CIJ2ZgjR6MUIKqSmeDmvpsKLsZ9jgXJmQPpwL8w==', 200, '2023-11-20 22:04:38', '2023-11-20 22:04:38', 100, 'postgres_cdc_rls', 100000, 100, 100, false); - - --- --- Data for Name: audit_log_entries; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: flow_state; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: instances; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_amr_claims; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_challenges; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: mfa_factors; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: saml_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: saml_relay_states; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: schema_migrations; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211738'); -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211808'); -INSERT INTO auth.schema_migrations (version) VALUES ('20171026211834'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180103212743'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180108183307'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180119214651'); -INSERT INTO auth.schema_migrations (version) VALUES ('20180125194653'); -INSERT INTO auth.schema_migrations (version) VALUES ('00'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210710035447'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210722035447'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210730183235'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210909172000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20210927181326'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211122151130'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211124214934'); -INSERT INTO auth.schema_migrations (version) VALUES ('20211202183645'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220114185221'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220114185340'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220224000811'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220323170000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220429102000'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220531120530'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220614074223'); -INSERT INTO auth.schema_migrations (version) VALUES ('20220811173540'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221003041349'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221003041400'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221011041400'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221020193600'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221021073300'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221021082433'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221027105023'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221114143122'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221114143410'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221125140132'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221208132122'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195500'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195800'); -INSERT INTO auth.schema_migrations (version) VALUES ('20221215195900'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230116124310'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230116124412'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230131181311'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230322519590'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230402418590'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230411005111'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230508135423'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230523124323'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230818113222'); -INSERT INTO auth.schema_migrations (version) VALUES ('20230914180801'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231027141322'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231114161723'); -INSERT INTO auth.schema_migrations (version) VALUES ('20231117164230'); - - --- --- Data for Name: sessions; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: sso_domains; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: sso_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: users; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin --- - - - --- --- Data for Name: buckets; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - - - --- --- Data for Name: migrations; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (0, 'create-migrations-table', 'e18db593bcde2aca2a408c4d1100f6abba2195df', '2023-03-16 02:25:37.548482'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (1, 'initialmigration', '6ab16121fbaa08bbd11b712d05f358f9b555d777', '2023-03-16 02:25:37.55595'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (2, 'pathtoken-column', '49756be03be4c17bb85fe70d4a861f27de7e49ad', '2023-03-16 02:25:37.560258'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (3, 'add-migrations-rls', 'bb5d124c53d68635a883e399426c6a5a25fc893d', '2023-03-16 02:25:37.594153'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (4, 'add-size-functions', '6d79007d04f5acd288c9c250c42d2d5fd286c54d', '2023-03-16 02:25:37.599194'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (5, 'change-column-name-in-get-size', 'fd65688505d2ffa9fbdc58a944348dd8604d688c', '2023-03-16 02:25:37.604658'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (6, 'add-rls-to-buckets', '63e2bab75a2040fee8e3fb3f15a0d26f3380e9b6', '2023-03-16 02:25:37.609563'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (7, 'add-public-to-buckets', '82568934f8a4d9e0a85f126f6fb483ad8214c418', '2023-03-16 02:25:37.615929'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (8, 'fix-search-function', '1a43a40eddb525f2e2f26efd709e6c06e58e059c', '2023-03-16 02:25:37.620171'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (9, 'search-files-search-function', '34c096597eb8b9d077fdfdde9878c88501b2fafc', '2023-03-16 02:25:37.624484'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (10, 'add-trigger-to-auto-update-updated_at-column', '37d6bb964a70a822e6d37f22f457b9bca7885928', '2023-03-16 02:25:37.629534'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (11, 'add-automatic-avif-detection-flag', 'bd76c53a9c564c80d98d119c1b3a28e16c8152db', '2023-03-16 02:25:37.634022'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (12, 'add-bucket-custom-limits', 'cbe0a4c32a0e891554a21020433b7a4423c07ee7', '2023-03-16 02:25:37.639132'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (13, 'use-bytes-for-max-size', '7a158ebce8a0c2801c9c65b7e9b2f98f68b3874e', '2023-03-16 02:25:37.643598'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (14, 'add-can-insert-object-function', '273193826bca7e0990b458d1ba72f8aa27c0d825', '2023-04-06 09:18:55.232202'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (15, 'add-version', 'e821a779d26612899b8c2dfe20245f904a327c4f', '2023-04-06 09:18:55.239444'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (16, 'drop-owner-foreign-key', '536b33f8878eed09d0144219777dcac96bdb25da', '2023-11-20 21:54:33.540386'); -INSERT INTO storage.migrations (id, name, hash, executed_at) VALUES (17, 'add_owner_id_column_deprecate_owner', '7545f216a39358b5487df75d941d05dbcd75eb46', '2023-11-20 22:04:38.959686'); - - --- --- Data for Name: objects; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin --- - - - --- --- Data for Name: hooks; Type: TABLE DATA; Schema: supabase_functions; Owner: supabase_functions_admin --- - - - --- --- Data for Name: migrations; Type: TABLE DATA; Schema: supabase_functions; Owner: supabase_functions_admin --- - -INSERT INTO supabase_functions.migrations (version, inserted_at) VALUES ('initial', '2023-03-16 02:25:30.966697+00'); -INSERT INTO supabase_functions.migrations (version, inserted_at) VALUES ('20210809183423_update_grants', '2023-03-16 02:25:30.966697+00'); - - --- --- Name: refresh_tokens_id_seq; Type: SEQUENCE SET; Schema: auth; Owner: supabase_auth_admin --- - -SELECT pg_catalog.setval('auth.refresh_tokens_id_seq', 1, false); - - --- --- Name: hooks_id_seq; Type: SEQUENCE SET; Schema: supabase_functions; Owner: supabase_functions_admin --- - -SELECT pg_catalog.setval('supabase_functions.hooks_id_seq', 1, false); - - --- --- Name: extensions extensions_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.extensions - ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - - --- --- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.tenants - ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); - - --- --- Name: mfa_amr_claims amr_id_pk; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT amr_id_pk PRIMARY KEY (id); - - --- --- Name: audit_log_entries audit_log_entries_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.audit_log_entries - ADD CONSTRAINT audit_log_entries_pkey PRIMARY KEY (id); - - --- --- Name: flow_state flow_state_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.flow_state - ADD CONSTRAINT flow_state_pkey PRIMARY KEY (id); - - --- --- Name: identities identities_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_pkey PRIMARY KEY (id); - - --- --- Name: identities identities_provider_id_provider_unique; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_provider_id_provider_unique UNIQUE (provider_id, provider); - - --- --- Name: instances instances_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.instances - ADD CONSTRAINT instances_pkey PRIMARY KEY (id); - - --- --- Name: mfa_amr_claims mfa_amr_claims_session_id_authentication_method_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey UNIQUE (session_id, authentication_method); - - --- --- Name: mfa_challenges mfa_challenges_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_pkey PRIMARY KEY (id); - - --- --- Name: mfa_factors mfa_factors_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_factors - ADD CONSTRAINT mfa_factors_pkey PRIMARY KEY (id); - - --- --- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); - - --- --- Name: refresh_tokens refresh_tokens_token_unique; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_token_unique UNIQUE (token); - - --- --- Name: saml_providers saml_providers_entity_id_key; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_entity_id_key UNIQUE (entity_id); - - --- --- Name: saml_providers saml_providers_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_pkey PRIMARY KEY (id); - - --- --- Name: saml_relay_states saml_relay_states_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - - --- --- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); - - --- --- Name: sso_domains sso_domains_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_domains - ADD CONSTRAINT sso_domains_pkey PRIMARY KEY (id); - - --- --- Name: sso_providers sso_providers_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_providers - ADD CONSTRAINT sso_providers_pkey PRIMARY KEY (id); - - --- --- Name: users users_phone_key; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.users - ADD CONSTRAINT users_phone_key UNIQUE (phone); - - --- --- Name: users users_pkey; Type: CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.users - ADD CONSTRAINT users_pkey PRIMARY KEY (id); - - --- --- Name: buckets buckets_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.buckets - ADD CONSTRAINT buckets_pkey PRIMARY KEY (id); - - --- --- Name: migrations migrations_name_key; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.migrations - ADD CONSTRAINT migrations_name_key UNIQUE (name); - - --- --- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); - - --- --- Name: objects objects_pkey; Type: CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.objects - ADD CONSTRAINT objects_pkey PRIMARY KEY (id); - - --- --- Name: hooks hooks_pkey; Type: CONSTRAINT; Schema: supabase_functions; Owner: supabase_functions_admin --- - -ALTER TABLE ONLY supabase_functions.hooks - ADD CONSTRAINT hooks_pkey PRIMARY KEY (id); - - --- --- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: supabase_functions; Owner: supabase_functions_admin --- - -ALTER TABLE ONLY supabase_functions.migrations - ADD CONSTRAINT migrations_pkey PRIMARY KEY (version); - - --- --- Name: extensions_tenant_external_id_type_index; Type: INDEX; Schema: _realtime; Owner: postgres --- - -CREATE UNIQUE INDEX extensions_tenant_external_id_type_index ON _realtime.extensions USING btree (tenant_external_id, type); - - --- --- Name: tenants_external_id_index; Type: INDEX; Schema: _realtime; Owner: postgres --- - -CREATE UNIQUE INDEX tenants_external_id_index ON _realtime.tenants USING btree (external_id); - - --- --- Name: audit_logs_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX audit_logs_instance_id_idx ON auth.audit_log_entries USING btree (instance_id); - - --- --- Name: confirmation_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX confirmation_token_idx ON auth.users USING btree (confirmation_token) WHERE ((confirmation_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: email_change_token_current_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX email_change_token_current_idx ON auth.users USING btree (email_change_token_current) WHERE ((email_change_token_current)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: email_change_token_new_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX email_change_token_new_idx ON auth.users USING btree (email_change_token_new) WHERE ((email_change_token_new)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: factor_id_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX factor_id_created_at_idx ON auth.mfa_factors USING btree (user_id, created_at); - - --- --- Name: flow_state_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX flow_state_created_at_idx ON auth.flow_state USING btree (created_at DESC); - - --- --- Name: identities_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX identities_email_idx ON auth.identities USING btree (email text_pattern_ops); - - --- --- Name: INDEX identities_email_idx; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON INDEX auth.identities_email_idx IS 'Auth: Ensures indexed queries on the email column'; - - --- --- Name: identities_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX identities_user_id_idx ON auth.identities USING btree (user_id); - - --- --- Name: idx_auth_code; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX idx_auth_code ON auth.flow_state USING btree (auth_code); - - --- --- Name: idx_user_id_auth_method; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX idx_user_id_auth_method ON auth.flow_state USING btree (user_id, authentication_method); - - --- --- Name: mfa_challenge_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX mfa_challenge_created_at_idx ON auth.mfa_challenges USING btree (created_at DESC); - - --- --- Name: mfa_factors_user_friendly_name_unique; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX mfa_factors_user_friendly_name_unique ON auth.mfa_factors USING btree (friendly_name, user_id) WHERE (TRIM(BOTH FROM friendly_name) <> ''::text); - - --- --- Name: mfa_factors_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX mfa_factors_user_id_idx ON auth.mfa_factors USING btree (user_id); - - --- --- Name: reauthentication_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX reauthentication_token_idx ON auth.users USING btree (reauthentication_token) WHERE ((reauthentication_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: recovery_token_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX recovery_token_idx ON auth.users USING btree (recovery_token) WHERE ((recovery_token)::text !~ '^[0-9 ]*$'::text); - - --- --- Name: refresh_tokens_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_instance_id_idx ON auth.refresh_tokens USING btree (instance_id); - - --- --- Name: refresh_tokens_instance_id_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_instance_id_user_id_idx ON auth.refresh_tokens USING btree (instance_id, user_id); - - --- --- Name: refresh_tokens_parent_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_parent_idx ON auth.refresh_tokens USING btree (parent); - - --- --- Name: refresh_tokens_session_id_revoked_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_session_id_revoked_idx ON auth.refresh_tokens USING btree (session_id, revoked); - - --- --- Name: refresh_tokens_updated_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX refresh_tokens_updated_at_idx ON auth.refresh_tokens USING btree (updated_at DESC); - - --- --- Name: saml_providers_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_providers_sso_provider_id_idx ON auth.saml_providers USING btree (sso_provider_id); - - --- --- Name: saml_relay_states_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_created_at_idx ON auth.saml_relay_states USING btree (created_at DESC); - - --- --- Name: saml_relay_states_for_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_for_email_idx ON auth.saml_relay_states USING btree (for_email); - - --- --- Name: saml_relay_states_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX saml_relay_states_sso_provider_id_idx ON auth.saml_relay_states USING btree (sso_provider_id); - - --- --- Name: sessions_not_after_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sessions_not_after_idx ON auth.sessions USING btree (not_after DESC); - - --- --- Name: sessions_user_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sessions_user_id_idx ON auth.sessions USING btree (user_id); - - --- --- Name: sso_domains_domain_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX sso_domains_domain_idx ON auth.sso_domains USING btree (lower(domain)); - - --- --- Name: sso_domains_sso_provider_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX sso_domains_sso_provider_id_idx ON auth.sso_domains USING btree (sso_provider_id); - - --- --- Name: sso_providers_resource_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX sso_providers_resource_id_idx ON auth.sso_providers USING btree (lower(resource_id)); - - --- --- Name: user_id_created_at_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX user_id_created_at_idx ON auth.sessions USING btree (user_id, created_at); - - --- --- Name: users_email_partial_key; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE UNIQUE INDEX users_email_partial_key ON auth.users USING btree (email) WHERE (is_sso_user = false); - - --- --- Name: INDEX users_email_partial_key; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin --- - -COMMENT ON INDEX auth.users_email_partial_key IS 'Auth: A partial unique index that applies only when is_sso_user is false'; - - --- --- Name: users_instance_id_email_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX users_instance_id_email_idx ON auth.users USING btree (instance_id, lower((email)::text)); - - --- --- Name: users_instance_id_idx; Type: INDEX; Schema: auth; Owner: supabase_auth_admin --- - -CREATE INDEX users_instance_id_idx ON auth.users USING btree (instance_id); - - --- --- Name: bname; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE UNIQUE INDEX bname ON storage.buckets USING btree (name); - - --- --- Name: bucketid_objname; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE UNIQUE INDEX bucketid_objname ON storage.objects USING btree (bucket_id, name); - - --- --- Name: name_prefix_search; Type: INDEX; Schema: storage; Owner: supabase_storage_admin --- - -CREATE INDEX name_prefix_search ON storage.objects USING btree (name text_pattern_ops); - - --- --- Name: supabase_functions_hooks_h_table_id_h_name_idx; Type: INDEX; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); - - --- --- Name: supabase_functions_hooks_request_id_idx; Type: INDEX; Schema: supabase_functions; Owner: supabase_functions_admin --- - -CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); - - --- --- Name: objects update_objects_updated_at; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin --- - -CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION storage.update_updated_at_column(); - - --- --- Name: extensions extensions_tenant_external_id_fkey; Type: FK CONSTRAINT; Schema: _realtime; Owner: postgres --- - -ALTER TABLE ONLY _realtime.extensions - ADD CONSTRAINT extensions_tenant_external_id_fkey FOREIGN KEY (tenant_external_id) REFERENCES _realtime.tenants(external_id) ON DELETE CASCADE; - - --- --- Name: identities identities_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.identities - ADD CONSTRAINT identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: mfa_amr_claims mfa_amr_claims_session_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_amr_claims - ADD CONSTRAINT mfa_amr_claims_session_id_fkey FOREIGN KEY (session_id) REFERENCES auth.sessions(id) ON DELETE CASCADE; - - --- --- Name: mfa_challenges mfa_challenges_auth_factor_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_challenges - ADD CONSTRAINT mfa_challenges_auth_factor_id_fkey FOREIGN KEY (factor_id) REFERENCES auth.mfa_factors(id) ON DELETE CASCADE; - - --- --- Name: mfa_factors mfa_factors_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.mfa_factors - ADD CONSTRAINT mfa_factors_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: refresh_tokens refresh_tokens_session_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.refresh_tokens - ADD CONSTRAINT refresh_tokens_session_id_fkey FOREIGN KEY (session_id) REFERENCES auth.sessions(id) ON DELETE CASCADE; - - --- --- Name: saml_providers saml_providers_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_providers - ADD CONSTRAINT saml_providers_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: saml_relay_states saml_relay_states_flow_state_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_flow_state_id_fkey FOREIGN KEY (flow_state_id) REFERENCES auth.flow_state(id) ON DELETE CASCADE; - - --- --- Name: saml_relay_states saml_relay_states_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.saml_relay_states - ADD CONSTRAINT saml_relay_states_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: sessions sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sessions - ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - - --- --- Name: sso_domains sso_domains_sso_provider_id_fkey; Type: FK CONSTRAINT; Schema: auth; Owner: supabase_auth_admin --- - -ALTER TABLE ONLY auth.sso_domains - ADD CONSTRAINT sso_domains_sso_provider_id_fkey FOREIGN KEY (sso_provider_id) REFERENCES auth.sso_providers(id) ON DELETE CASCADE; - - --- --- Name: objects objects_bucketId_fkey; Type: FK CONSTRAINT; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE ONLY storage.objects - ADD CONSTRAINT "objects_bucketId_fkey" FOREIGN KEY (bucket_id) REFERENCES storage.buckets(id); - - --- --- Name: buckets; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY; - --- --- Name: migrations; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.migrations ENABLE ROW LEVEL SECURITY; - --- --- Name: objects; Type: ROW SECURITY; Schema: storage; Owner: supabase_storage_admin --- - -ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; - --- --- Name: supabase_realtime; Type: PUBLICATION; Schema: -; Owner: postgres --- - -CREATE PUBLICATION supabase_realtime WITH (publish = 'insert, update, delete, truncate'); - - -ALTER PUBLICATION supabase_realtime OWNER TO postgres; - --- --- Name: SCHEMA auth; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA auth TO anon; -GRANT USAGE ON SCHEMA auth TO authenticated; -GRANT USAGE ON SCHEMA auth TO service_role; -GRANT ALL ON SCHEMA auth TO supabase_auth_admin; -GRANT ALL ON SCHEMA auth TO dashboard_user; -GRANT ALL ON SCHEMA auth TO postgres; - - --- --- Name: SCHEMA extensions; Type: ACL; Schema: -; Owner: postgres --- - -GRANT USAGE ON SCHEMA extensions TO anon; -GRANT USAGE ON SCHEMA extensions TO authenticated; -GRANT USAGE ON SCHEMA extensions TO service_role; -GRANT ALL ON SCHEMA extensions TO dashboard_user; - - --- --- Name: SCHEMA graphql_public; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA graphql_public TO postgres; -GRANT USAGE ON SCHEMA graphql_public TO anon; -GRANT USAGE ON SCHEMA graphql_public TO authenticated; -GRANT USAGE ON SCHEMA graphql_public TO service_role; - - --- --- Name: SCHEMA net; Type: ACL; Schema: -; Owner: postgres --- - -GRANT USAGE ON SCHEMA net TO supabase_functions_admin; -GRANT USAGE ON SCHEMA net TO anon; -GRANT USAGE ON SCHEMA net TO authenticated; -GRANT USAGE ON SCHEMA net TO service_role; - - --- --- Name: SCHEMA public; Type: ACL; Schema: -; Owner: postgres --- - -REVOKE USAGE ON SCHEMA public FROM PUBLIC; -GRANT ALL ON SCHEMA public TO PUBLIC; -GRANT USAGE ON SCHEMA public TO anon; -GRANT USAGE ON SCHEMA public TO authenticated; -GRANT USAGE ON SCHEMA public TO service_role; - - --- --- Name: SCHEMA realtime; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA realtime TO postgres; - - --- --- Name: SCHEMA storage; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT ALL ON SCHEMA storage TO postgres; -GRANT USAGE ON SCHEMA storage TO anon; -GRANT USAGE ON SCHEMA storage TO authenticated; -GRANT USAGE ON SCHEMA storage TO service_role; -GRANT ALL ON SCHEMA storage TO supabase_storage_admin; -GRANT ALL ON SCHEMA storage TO dashboard_user; - - --- --- Name: SCHEMA supabase_functions; Type: ACL; Schema: -; Owner: supabase_admin --- - -GRANT USAGE ON SCHEMA supabase_functions TO postgres; -GRANT USAGE ON SCHEMA supabase_functions TO anon; -GRANT USAGE ON SCHEMA supabase_functions TO authenticated; -GRANT USAGE ON SCHEMA supabase_functions TO service_role; -GRANT ALL ON SCHEMA supabase_functions TO supabase_functions_admin; - - --- --- Name: FUNCTION email(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.email() TO dashboard_user; - - --- --- Name: FUNCTION jwt(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.jwt() TO postgres; -GRANT ALL ON FUNCTION auth.jwt() TO dashboard_user; - - --- --- Name: FUNCTION role(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.role() TO dashboard_user; - - --- --- Name: FUNCTION uid(); Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON FUNCTION auth.uid() TO dashboard_user; - - --- --- Name: FUNCTION algorithm_sign(signables text, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.algorithm_sign(signables text, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION armor(bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.armor(bytea) TO dashboard_user; - - --- --- Name: FUNCTION armor(bytea, text[], text[]); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.armor(bytea, text[], text[]) TO dashboard_user; - - --- --- Name: FUNCTION crypt(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.crypt(text, text) TO dashboard_user; - - --- --- Name: FUNCTION dearmor(text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.dearmor(text) TO dashboard_user; - - --- --- Name: FUNCTION decrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.decrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION decrypt_iv(bytea, bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.decrypt_iv(bytea, bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION digest(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.digest(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION digest(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.digest(text, text) TO dashboard_user; - - --- --- Name: FUNCTION encrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.encrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION encrypt_iv(bytea, bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.encrypt_iv(bytea, bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION gen_random_bytes(integer); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_random_bytes(integer) TO dashboard_user; - - --- --- Name: FUNCTION gen_random_uuid(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_random_uuid() TO dashboard_user; - - --- --- Name: FUNCTION gen_salt(text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_salt(text) TO dashboard_user; - - --- --- Name: FUNCTION gen_salt(text, integer); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.gen_salt(text, integer) TO dashboard_user; - - --- --- Name: FUNCTION grant_pg_cron_access(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.grant_pg_cron_access() TO dashboard_user; - - --- --- Name: FUNCTION grant_pg_net_access(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.grant_pg_net_access() TO dashboard_user; - - --- --- Name: FUNCTION hmac(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.hmac(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION hmac(text, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.hmac(text, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pg_stat_statements(showtext boolean, OUT userid oid, OUT dbid oid, OUT toplevel boolean, OUT queryid bigint, OUT query text, OUT plans bigint, OUT total_plan_time double precision, OUT min_plan_time double precision, OUT max_plan_time double precision, OUT mean_plan_time double precision, OUT stddev_plan_time double precision, OUT calls bigint, OUT total_exec_time double precision, OUT min_exec_time double precision, OUT max_exec_time double precision, OUT mean_exec_time double precision, OUT stddev_exec_time double precision, OUT rows bigint, OUT shared_blks_hit bigint, OUT shared_blks_read bigint, OUT shared_blks_dirtied bigint, OUT shared_blks_written bigint, OUT local_blks_hit bigint, OUT local_blks_read bigint, OUT local_blks_dirtied bigint, OUT local_blks_written bigint, OUT temp_blks_read bigint, OUT temp_blks_written bigint, OUT blk_read_time double precision, OUT blk_write_time double precision, OUT wal_records bigint, OUT wal_fpi bigint, OUT wal_bytes numeric); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pg_stat_statements(showtext boolean, OUT userid oid, OUT dbid oid, OUT toplevel boolean, OUT queryid bigint, OUT query text, OUT plans bigint, OUT total_plan_time double precision, OUT min_plan_time double precision, OUT max_plan_time double precision, OUT mean_plan_time double precision, OUT stddev_plan_time double precision, OUT calls bigint, OUT total_exec_time double precision, OUT min_exec_time double precision, OUT max_exec_time double precision, OUT mean_exec_time double precision, OUT stddev_exec_time double precision, OUT rows bigint, OUT shared_blks_hit bigint, OUT shared_blks_read bigint, OUT shared_blks_dirtied bigint, OUT shared_blks_written bigint, OUT local_blks_hit bigint, OUT local_blks_read bigint, OUT local_blks_dirtied bigint, OUT local_blks_written bigint, OUT temp_blks_read bigint, OUT temp_blks_written bigint, OUT blk_read_time double precision, OUT blk_write_time double precision, OUT wal_records bigint, OUT wal_fpi bigint, OUT wal_bytes numeric) TO dashboard_user; - - --- --- Name: FUNCTION pg_stat_statements_info(OUT dealloc bigint, OUT stats_reset timestamp with time zone); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pg_stat_statements_info(OUT dealloc bigint, OUT stats_reset timestamp with time zone) TO dashboard_user; - - --- --- Name: FUNCTION pg_stat_statements_reset(userid oid, dbid oid, queryid bigint); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pg_stat_statements_reset(userid oid, dbid oid, queryid bigint) TO dashboard_user; - - --- --- Name: FUNCTION pgp_armor_headers(text, OUT key text, OUT value text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_armor_headers(text, OUT key text, OUT value text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_key_id(bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_key_id(bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt(bytea, bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_decrypt_bytea(bytea, bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt(text, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt(text, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt(text, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt(text, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt_bytea(bytea, bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea) TO dashboard_user; - - --- --- Name: FUNCTION pgp_pub_encrypt_bytea(bytea, bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt_bytea(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_decrypt_bytea(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt(text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt(text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt(text, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt(text, text, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt_bytea(bytea, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text) TO dashboard_user; - - --- --- Name: FUNCTION pgp_sym_encrypt_bytea(bytea, text, text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text, text) TO dashboard_user; - - --- --- Name: FUNCTION sign(payload json, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.sign(payload json, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION try_cast_double(inp text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.try_cast_double(inp text) TO dashboard_user; - - --- --- Name: FUNCTION url_decode(data text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.url_decode(data text) TO dashboard_user; - - --- --- Name: FUNCTION url_encode(data bytea); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.url_encode(data bytea) TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v1(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v1() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v1mc(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v1mc() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v3(namespace uuid, name text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v3(namespace uuid, name text) TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v4(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v4() TO dashboard_user; - - --- --- Name: FUNCTION uuid_generate_v5(namespace uuid, name text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_generate_v5(namespace uuid, name text) TO dashboard_user; - - --- --- Name: FUNCTION uuid_nil(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_nil() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_dns(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_dns() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_oid(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_oid() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_url(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_url() TO dashboard_user; - - --- --- Name: FUNCTION uuid_ns_x500(); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.uuid_ns_x500() TO dashboard_user; - - --- --- Name: FUNCTION verify(token text, secret text, algorithm text); Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON FUNCTION extensions.verify(token text, secret text, algorithm text) TO dashboard_user; - - --- --- Name: FUNCTION comment_directive(comment_ text); Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON FUNCTION graphql.comment_directive(comment_ text) TO postgres; -GRANT ALL ON FUNCTION graphql.comment_directive(comment_ text) TO anon; -GRANT ALL ON FUNCTION graphql.comment_directive(comment_ text) TO authenticated; -GRANT ALL ON FUNCTION graphql.comment_directive(comment_ text) TO service_role; - - --- --- Name: FUNCTION exception(message text); Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON FUNCTION graphql.exception(message text) TO postgres; -GRANT ALL ON FUNCTION graphql.exception(message text) TO anon; -GRANT ALL ON FUNCTION graphql.exception(message text) TO authenticated; -GRANT ALL ON FUNCTION graphql.exception(message text) TO service_role; - - --- --- Name: FUNCTION get_schema_version(); Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON FUNCTION graphql.get_schema_version() TO postgres; -GRANT ALL ON FUNCTION graphql.get_schema_version() TO anon; -GRANT ALL ON FUNCTION graphql.get_schema_version() TO authenticated; -GRANT ALL ON FUNCTION graphql.get_schema_version() TO service_role; - - --- --- Name: FUNCTION increment_schema_version(); Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON FUNCTION graphql.increment_schema_version() TO postgres; -GRANT ALL ON FUNCTION graphql.increment_schema_version() TO anon; -GRANT ALL ON FUNCTION graphql.increment_schema_version() TO authenticated; -GRANT ALL ON FUNCTION graphql.increment_schema_version() TO service_role; - - --- --- Name: FUNCTION sequential_executor(prepared_statement_names text[]); Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON FUNCTION graphql.sequential_executor(prepared_statement_names text[]) TO postgres; -GRANT ALL ON FUNCTION graphql.sequential_executor(prepared_statement_names text[]) TO anon; -GRANT ALL ON FUNCTION graphql.sequential_executor(prepared_statement_names text[]) TO authenticated; -GRANT ALL ON FUNCTION graphql.sequential_executor(prepared_statement_names text[]) TO service_role; - - --- --- Name: FUNCTION graphql("operationName" text, query text, variables jsonb, extensions jsonb); Type: ACL; Schema: graphql_public; Owner: supabase_admin --- - --- GRANT ALL ON FUNCTION graphql_public.graphql("operationName" text, query text, variables jsonb, extensions jsonb) TO postgres; --- GRANT ALL ON FUNCTION graphql_public.graphql("operationName" text, query text, variables jsonb, extensions jsonb) TO anon; --- GRANT ALL ON FUNCTION graphql_public.graphql("operationName" text, query text, variables jsonb, extensions jsonb) TO authenticated; --- GRANT ALL ON FUNCTION graphql_public.graphql("operationName" text, query text, variables jsonb, extensions jsonb) TO service_role; - - --- --- Name: FUNCTION http_collect_response(request_id bigint, async boolean); Type: ACL; Schema: net; Owner: supabase_admin --- - -REVOKE ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) FROM PUBLIC; -GRANT ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO supabase_functions_admin; -GRANT ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO postgres; -GRANT ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO anon; -GRANT ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO authenticated; -GRANT ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO service_role; - - --- --- Name: FUNCTION http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer); Type: ACL; Schema: net; Owner: supabase_admin --- - -REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; -GRANT ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin; -GRANT ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO postgres; -GRANT ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO anon; -GRANT ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO authenticated; -GRANT ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO service_role; - - --- --- Name: FUNCTION http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer); Type: ACL; Schema: net; Owner: supabase_admin --- - -REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; -GRANT ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin; -GRANT ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO postgres; -GRANT ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO anon; -GRANT ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO authenticated; -GRANT ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO service_role; - - --- --- Name: FUNCTION get_auth(p_usename text); Type: ACL; Schema: pgbouncer; Owner: postgres --- - -REVOKE ALL ON FUNCTION pgbouncer.get_auth(p_usename text) FROM PUBLIC; -GRANT ALL ON FUNCTION pgbouncer.get_auth(p_usename text) TO pgbouncer; - - --- --- Name: SEQUENCE key_key_id_seq; Type: ACL; Schema: pgsodium; Owner: postgres --- - -GRANT ALL ON SEQUENCE pgsodium.key_key_id_seq TO pgsodium_keyiduser; - - --- --- Name: FUNCTION extension(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.extension(name text) TO anon; -GRANT ALL ON FUNCTION storage.extension(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.extension(name text) TO service_role; -GRANT ALL ON FUNCTION storage.extension(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.extension(name text) TO postgres; - - --- --- Name: FUNCTION filename(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.filename(name text) TO anon; -GRANT ALL ON FUNCTION storage.filename(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.filename(name text) TO service_role; -GRANT ALL ON FUNCTION storage.filename(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.filename(name text) TO postgres; - - --- --- Name: FUNCTION foldername(name text); Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON FUNCTION storage.foldername(name text) TO anon; -GRANT ALL ON FUNCTION storage.foldername(name text) TO authenticated; -GRANT ALL ON FUNCTION storage.foldername(name text) TO service_role; -GRANT ALL ON FUNCTION storage.foldername(name text) TO dashboard_user; -GRANT ALL ON FUNCTION storage.foldername(name text) TO postgres; - - --- --- Name: FUNCTION http_request(); Type: ACL; Schema: supabase_functions; Owner: supabase_functions_admin --- - -REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; -GRANT ALL ON FUNCTION supabase_functions.http_request() TO postgres; -GRANT ALL ON FUNCTION supabase_functions.http_request() TO anon; -GRANT ALL ON FUNCTION supabase_functions.http_request() TO authenticated; -GRANT ALL ON FUNCTION supabase_functions.http_request() TO service_role; - - --- --- Name: TABLE audit_log_entries; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.audit_log_entries TO dashboard_user; -GRANT ALL ON TABLE auth.audit_log_entries TO postgres; - - --- --- Name: TABLE flow_state; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.flow_state TO postgres; -GRANT ALL ON TABLE auth.flow_state TO dashboard_user; - - --- --- Name: TABLE identities; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.identities TO postgres; -GRANT ALL ON TABLE auth.identities TO dashboard_user; - - --- --- Name: TABLE instances; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.instances TO dashboard_user; -GRANT ALL ON TABLE auth.instances TO postgres; - - --- --- Name: TABLE mfa_amr_claims; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_amr_claims TO postgres; -GRANT ALL ON TABLE auth.mfa_amr_claims TO dashboard_user; - - --- --- Name: TABLE mfa_challenges; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_challenges TO postgres; -GRANT ALL ON TABLE auth.mfa_challenges TO dashboard_user; - - --- --- Name: TABLE mfa_factors; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.mfa_factors TO postgres; -GRANT ALL ON TABLE auth.mfa_factors TO dashboard_user; - - --- --- Name: TABLE refresh_tokens; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.refresh_tokens TO dashboard_user; -GRANT ALL ON TABLE auth.refresh_tokens TO postgres; - - --- --- Name: SEQUENCE refresh_tokens_id_seq; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON SEQUENCE auth.refresh_tokens_id_seq TO dashboard_user; -GRANT ALL ON SEQUENCE auth.refresh_tokens_id_seq TO postgres; - - --- --- Name: TABLE saml_providers; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.saml_providers TO postgres; -GRANT ALL ON TABLE auth.saml_providers TO dashboard_user; - - --- --- Name: TABLE saml_relay_states; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.saml_relay_states TO postgres; -GRANT ALL ON TABLE auth.saml_relay_states TO dashboard_user; - - --- --- Name: TABLE schema_migrations; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.schema_migrations TO dashboard_user; -GRANT ALL ON TABLE auth.schema_migrations TO postgres; - - --- --- Name: TABLE sessions; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sessions TO postgres; -GRANT ALL ON TABLE auth.sessions TO dashboard_user; - - --- --- Name: TABLE sso_domains; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sso_domains TO postgres; -GRANT ALL ON TABLE auth.sso_domains TO dashboard_user; - - --- --- Name: TABLE sso_providers; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.sso_providers TO postgres; -GRANT ALL ON TABLE auth.sso_providers TO dashboard_user; - - --- --- Name: TABLE users; Type: ACL; Schema: auth; Owner: supabase_auth_admin --- - -GRANT ALL ON TABLE auth.users TO dashboard_user; -GRANT ALL ON TABLE auth.users TO postgres; - - --- --- Name: TABLE pg_stat_statements; Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON TABLE extensions.pg_stat_statements TO dashboard_user; - - --- --- Name: TABLE pg_stat_statements_info; Type: ACL; Schema: extensions; Owner: postgres --- - -GRANT ALL ON TABLE extensions.pg_stat_statements_info TO dashboard_user; - - --- --- Name: SEQUENCE seq_schema_version; Type: ACL; Schema: graphql; Owner: supabase_admin --- - -GRANT ALL ON SEQUENCE graphql.seq_schema_version TO postgres; -GRANT ALL ON SEQUENCE graphql.seq_schema_version TO anon; -GRANT ALL ON SEQUENCE graphql.seq_schema_version TO authenticated; -GRANT ALL ON SEQUENCE graphql.seq_schema_version TO service_role; - - --- --- Name: TABLE valid_key; Type: ACL; Schema: pgsodium; Owner: postgres --- - -GRANT ALL ON TABLE pgsodium.valid_key TO pgsodium_keyiduser; - - --- --- Name: TABLE buckets; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.buckets TO anon; -GRANT ALL ON TABLE storage.buckets TO authenticated; -GRANT ALL ON TABLE storage.buckets TO service_role; -GRANT ALL ON TABLE storage.buckets TO postgres; - - --- --- Name: TABLE migrations; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.migrations TO anon; -GRANT ALL ON TABLE storage.migrations TO authenticated; -GRANT ALL ON TABLE storage.migrations TO service_role; -GRANT ALL ON TABLE storage.migrations TO postgres; - - --- --- Name: TABLE objects; Type: ACL; Schema: storage; Owner: supabase_storage_admin --- - -GRANT ALL ON TABLE storage.objects TO anon; -GRANT ALL ON TABLE storage.objects TO authenticated; -GRANT ALL ON TABLE storage.objects TO service_role; -GRANT ALL ON TABLE storage.objects TO postgres; - - --- --- Name: TABLE hooks; Type: ACL; Schema: supabase_functions; Owner: supabase_functions_admin --- - -GRANT ALL ON TABLE supabase_functions.hooks TO postgres; -GRANT ALL ON TABLE supabase_functions.hooks TO anon; -GRANT ALL ON TABLE supabase_functions.hooks TO authenticated; -GRANT ALL ON TABLE supabase_functions.hooks TO service_role; - - --- --- Name: SEQUENCE hooks_id_seq; Type: ACL; Schema: supabase_functions; Owner: supabase_functions_admin --- - -GRANT ALL ON SEQUENCE supabase_functions.hooks_id_seq TO postgres; -GRANT ALL ON SEQUENCE supabase_functions.hooks_id_seq TO anon; -GRANT ALL ON SEQUENCE supabase_functions.hooks_id_seq TO authenticated; -GRANT ALL ON SEQUENCE supabase_functions.hooks_id_seq TO service_role; - - --- --- Name: TABLE migrations; Type: ACL; Schema: supabase_functions; Owner: supabase_functions_admin --- - -GRANT ALL ON TABLE supabase_functions.migrations TO postgres; -GRANT ALL ON TABLE supabase_functions.migrations TO anon; -GRANT ALL ON TABLE supabase_functions.migrations TO authenticated; -GRANT ALL ON TABLE supabase_functions.migrations TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON SEQUENCES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON FUNCTIONS TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: auth; Owner: supabase_auth_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_auth_admin IN SCHEMA auth GRANT ALL ON TABLES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: graphql; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: graphql; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: graphql; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: graphql; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: graphql; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: graphql; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA graphql GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: graphql_public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA graphql_public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: pgsodium; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA pgsodium GRANT ALL ON SEQUENCES TO pgsodium_keyiduser; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: pgsodium; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA pgsodium GRANT ALL ON TABLES TO pgsodium_keyiduser; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON SEQUENCES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON FUNCTIONS TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: realtime; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA realtime GRANT ALL ON TABLES TO dashboard_user; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: storage; Owner: postgres --- - -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA storage GRANT ALL ON TABLES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: supabase_functions; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: supabase_functions; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO service_role; - - --- --- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: supabase_functions; Owner: supabase_admin --- - -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA supabase_functions GRANT ALL ON TABLES TO service_role; - - --- --- Name: issue_graphql_placeholder; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_graphql_placeholder ON sql_drop - WHEN TAG IN ('DROP EXTENSION') - EXECUTE FUNCTION extensions.set_graphql_placeholder(); - - -ALTER EVENT TRIGGER issue_graphql_placeholder OWNER TO supabase_admin; - --- --- Name: issue_pg_cron_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_cron_access ON ddl_command_end - WHEN TAG IN ('CREATE SCHEMA') - EXECUTE FUNCTION extensions.grant_pg_cron_access(); - - -ALTER EVENT TRIGGER issue_pg_cron_access OWNER TO supabase_admin; - --- --- Name: issue_pg_graphql_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_graphql_access ON ddl_command_end - WHEN TAG IN ('CREATE FUNCTION') - EXECUTE FUNCTION extensions.grant_pg_graphql_access(); - - -ALTER EVENT TRIGGER issue_pg_graphql_access OWNER TO supabase_admin; - --- --- Name: issue_pg_net_access; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end - WHEN TAG IN ('CREATE EXTENSION') - EXECUTE FUNCTION extensions.grant_pg_net_access(); - - -ALTER EVENT TRIGGER issue_pg_net_access OWNER TO supabase_admin; - --- --- Name: pgrst_ddl_watch; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER pgrst_ddl_watch ON ddl_command_end - EXECUTE FUNCTION extensions.pgrst_ddl_watch(); - - -ALTER EVENT TRIGGER pgrst_ddl_watch OWNER TO supabase_admin; - --- --- Name: pgrst_drop_watch; Type: EVENT TRIGGER; Schema: -; Owner: supabase_admin --- - -CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop - EXECUTE FUNCTION extensions.pgrst_drop_watch(); - - -ALTER EVENT TRIGGER pgrst_drop_watch OWNER TO supabase_admin; - --- --- PostgreSQL database dump complete --- - -DROP EXTENSION pg_graphql; CREATE EXTENSION pg_graphql WITH SCHEMA graphql; -RESET ALL; diff --git a/apps/cli-go/internal/utils/templates/initial_schemas/README.md b/apps/cli-go/internal/utils/templates/initial_schemas/README.md deleted file mode 100644 index 77cfae04c6..0000000000 --- a/apps/cli-go/internal/utils/templates/initial_schemas/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# About Initial Schemas - -These SQL files represent initial schemas needed to set up the database with Supabase stuff. These need to be manually generated for each Postgres major version. Which initial schema used depends on the Docker image tag used to run the local db, which in turn depends on the CLI's `db.major_version` config. - -The initial schema for PG12 is not available because the latest image (`supabase/postgres:12.5.0`) doesn't contain `wal2json`, which is required for Realtime to work. - -# Why use the pg_dump output instead of running the `init.sql` directly? - -Because Realtime, GoTrue, Logflare, and Storage have their own migrations, and these need to be included in the initial schema for e.g. `supabase db reset` to work. - -# How to Generate Initial Schemas - -1. Start supabase local development stack with default config - -```bash -go run . init -go run . start -x gotrue,storage-api,imgproxy -``` - -2. Run the initial dump script and pipe output to `15.sql` - -```bash -./tools/dump_initial_schema.sh > internal/utils/templates/initial_schemas/15.sql -``` - -3. Commit changes that are relevant for the update - -- `INSERT INTO _realtime.extensions` and `_realtime.tenants` usually do not require updating. -- `INSERT INTO *.schema_migrations` statements are only required if there are new migrations. -- `ALTER EVENT TRIGGER issue_pg_cron_access OWNER TO` should be followed by `supabase_admin`. - -4. Set `major_version = 14` in `supabase/config.toml` and repeat steps 1-3 to update `14.sql` -5. Set `major_version = 13` in `supabase/config.toml` and repeat steps 1-3 to update `13.sql` diff --git a/apps/cli-go/internal/utils/tenant/client.go b/apps/cli-go/internal/utils/tenant/client.go deleted file mode 100644 index 912387739e..0000000000 --- a/apps/cli-go/internal/utils/tenant/client.go +++ /dev/null @@ -1,97 +0,0 @@ -package tenant - -import ( - "context" - "strings" - - "github.com/go-errors/errors" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/fetcher" -) - -var ( - ErrAuthToken = errors.New("Authorization failed for the access token and project ref pair") - errMissingKey = errors.New("Anon key not found.") -) - -type ApiKey struct { - Anon string - ServiceRole string -} - -func (a ApiKey) IsEmpty() bool { - return len(a.Anon) == 0 && len(a.ServiceRole) == 0 -} - -func NewApiKey(resp []api.ApiKeyResponseOutput) ApiKey { - var result ApiKey - for _, key := range resp { - value, err := key.ApiKey.Get() - if err != nil { - continue - } - if t, err := key.Type.Get(); err == nil { - switch t { - case api.ApiKeyResponseOutputTypePublishable: - result.Anon = value - continue - case api.ApiKeyResponseOutputTypeSecret: - if isServiceRole(key) { - result.ServiceRole = value - } - continue - } - } - switch key.Name { - case "anon": - if len(result.Anon) == 0 { - result.Anon = value - } - case "service_role": - if len(result.ServiceRole) == 0 { - result.ServiceRole = value - } - } - } - return result -} - -func isServiceRole(key api.ApiKeyResponseOutput) bool { - if tmpl, err := key.SecretJwtTemplate.Get(); err == nil { - if role, ok := tmpl["role"].(string); ok { - return strings.EqualFold(role, "service_role") - } - } - return false -} - -func GetApiKeys(ctx context.Context, projectRef string) (ApiKey, error) { - resp, err := utils.GetSupabase().V1GetProjectApiKeysWithResponse(ctx, projectRef, &api.V1GetProjectApiKeysParams{ - Reveal: cast.Ptr("true"), - }) - if err != nil { - return ApiKey{}, errors.Errorf("failed to get api keys: %w", err) - } - if resp.JSON200 == nil { - return ApiKey{}, errors.Errorf("%w: %s", ErrAuthToken, string(resp.Body)) - } - keys := NewApiKey(*resp.JSON200) - if keys.IsEmpty() { - return ApiKey{}, errors.New(errMissingKey) - } - return keys, nil -} - -type TenantAPI struct { - *fetcher.Fetcher -} - -func NewTenantAPI(ctx context.Context, projectRef, serviceKey string) TenantAPI { - return TenantAPI{Fetcher: fetcher.NewServiceGateway( - "https://"+utils.GetSupabaseHost(projectRef), - serviceKey, - fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - )} -} diff --git a/apps/cli-go/internal/utils/tenant/client_test.go b/apps/cli-go/internal/utils/tenant/client_test.go deleted file mode 100644 index ac29cfc403..0000000000 --- a/apps/cli-go/internal/utils/tenant/client_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package tenant - -import ( - "context" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/oapi-codegen/nullable" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" -) - -func TestApiKey(t *testing.T) { - t.Run("creates api key from response", func(t *testing.T) { - resp := []api.ApiKeyResponseOutput{ - {Name: "anon", ApiKey: nullable.NewNullableWithValue("anon-key")}, - {Name: "service_role", ApiKey: nullable.NewNullableWithValue("service-key")}, - } - - keys := NewApiKey(resp) - - assert.Equal(t, "anon-key", keys.Anon) - assert.Equal(t, "service-key", keys.ServiceRole) - assert.False(t, keys.IsEmpty()) - }) - - t.Run("handles empty response", func(t *testing.T) { - resp := []api.ApiKeyResponseOutput{ - {Name: "service_role", ApiKey: nullable.NewNullNullable[string]()}, - } - - keys := NewApiKey(resp) - - assert.Empty(t, keys.Anon) - assert.Empty(t, keys.ServiceRole) - assert.True(t, keys.IsEmpty()) - }) - - t.Run("handles partial response", func(t *testing.T) { - resp := []api.ApiKeyResponseOutput{ - {Name: "anon", ApiKey: nullable.NewNullableWithValue("anon-key")}, - } - - keys := NewApiKey(resp) - - assert.Equal(t, "anon-key", keys.Anon) - assert.Empty(t, keys.ServiceRole) - assert.False(t, keys.IsEmpty()) - }) -} - -func TestGetApiKeys(t *testing.T) { - t.Run("retrieves api keys successfully", func(t *testing.T) { - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef + "/api-keys"). - Reply(http.StatusOK). - JSON([]api.ApiKeyResponseOutput{ - {Name: "anon", ApiKey: nullable.NewNullableWithValue("anon-key")}, - {Name: "service_role", ApiKey: nullable.NewNullableWithValue("service-key")}, - }) - - keys, err := GetApiKeys(context.Background(), projectRef) - - assert.NoError(t, err) - assert.Equal(t, "anon-key", keys.Anon) - assert.Equal(t, "service-key", keys.ServiceRole) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles network error", func(t *testing.T) { - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef + "/api-keys"). - ReplyError(gock.ErrCannotMatch) - - keys, err := GetApiKeys(context.Background(), projectRef) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get api keys") - assert.True(t, keys.IsEmpty()) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles unauthorized error", func(t *testing.T) { - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef + "/api-keys"). - Reply(http.StatusUnauthorized). - JSON(map[string]string{"message": "unauthorized"}) - - keys, err := GetApiKeys(context.Background(), projectRef) - - assert.ErrorIs(t, err, ErrAuthToken) - assert.True(t, keys.IsEmpty()) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles missing anon key", func(t *testing.T) { - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef + "/api-keys"). - Reply(http.StatusOK). - JSON([]api.ApiKeyResponseOutput{}) // should this error if response has only service_role key? - - keys, err := GetApiKeys(context.Background(), projectRef) - - assert.ErrorIs(t, err, errMissingKey) - assert.True(t, keys.IsEmpty()) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestNewTenantAPI(t *testing.T) { - projectRef := apitest.RandomProjectRef() - anonKey := "test-key" - - api := NewTenantAPI(context.Background(), projectRef, anonKey) - assert.NotNil(t, api.Fetcher) - - defer gock.OffAll() - gock.New("https://"+utils.GetSupabaseHost(projectRef)). - Get("/test"). - MatchHeader("Authorization", "Bearer "+anonKey). - MatchHeader("User-Agent", "SupabaseCLI/"+utils.Version). - Reply(http.StatusOK) - - _, err := api.Send(context.Background(), http.MethodGet, "/test", nil) - - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) -} diff --git a/apps/cli-go/internal/utils/tenant/database.go b/apps/cli-go/internal/utils/tenant/database.go deleted file mode 100644 index ababf74af2..0000000000 --- a/apps/cli-go/internal/utils/tenant/database.go +++ /dev/null @@ -1,24 +0,0 @@ -package tenant - -import ( - "context" - - "github.com/go-errors/errors" - "github.com/supabase/cli/internal/utils" -) - -var errDatabaseVersion = errors.New("Database version not found.") - -func GetDatabaseVersion(ctx context.Context, projectRef string) (string, error) { - resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef) - if err != nil { - return "", errors.Errorf("failed to retrieve project: %w", err) - } - if resp.JSON200 == nil { - return "", errors.Errorf("unexpected retrieve project status %d: %s", resp.StatusCode(), string(resp.Body)) - } - if len(resp.JSON200.Database.Version) > 0 { - return resp.JSON200.Database.Version, nil - } - return "", errors.New(errDatabaseVersion) -} diff --git a/apps/cli-go/internal/utils/tenant/database_test.go b/apps/cli-go/internal/utils/tenant/database_test.go deleted file mode 100644 index 5fbbdadb1a..0000000000 --- a/apps/cli-go/internal/utils/tenant/database_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package tenant - -import ( - "context" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/api" -) - -func TestGetDatabaseVersion(t *testing.T) { - // Setup valid access token - token := apitest.RandomAccessToken(t) - t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) - // Setup valid project ref - projectRef := apitest.RandomProjectRef() - - t.Run("retrieves database version successfully", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - mockPostgres := api.V1ProjectWithDatabaseResponseOutput{} - mockPostgres.Database.Version = "14.1.0.99" - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef). - Reply(http.StatusOK). - JSON(mockPostgres) - // Run test - version, err := GetDatabaseVersion(context.Background(), projectRef) - // Check error - assert.NoError(t, err) - assert.Equal(t, "14.1.0.99", version) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles project not found", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef). - Reply(http.StatusNotFound) - // Run test - version, err := GetDatabaseVersion(context.Background(), projectRef) - // Check error - assert.ErrorContains(t, err, "unexpected retrieve project status 404:") - assert.Empty(t, version) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("handles empty database version", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - projectRef := apitest.RandomProjectRef() - gock.New(utils.DefaultApiHost). - Get("/v1/projects/" + projectRef). - Reply(http.StatusOK). - JSON(api.V1ProjectWithDatabaseResponseOutput{}) - // Run test - version, err := GetDatabaseVersion(context.Background(), projectRef) - // Check error - assert.ErrorIs(t, err, errDatabaseVersion) - assert.Empty(t, version) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/utils/tenant/gotrue.go b/apps/cli-go/internal/utils/tenant/gotrue.go deleted file mode 100644 index 507a63af5f..0000000000 --- a/apps/cli-go/internal/utils/tenant/gotrue.go +++ /dev/null @@ -1,32 +0,0 @@ -package tenant - -import ( - "context" - "net/http" - - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/fetcher" -) - -var errGotrueVersion = errors.New("GoTrue version not found.") - -type HealthResponse struct { - Version string `json:"version"` - Name string `json:"name"` - Description string `json:"description"` -} - -func (t *TenantAPI) GetGotrueVersion(ctx context.Context) (string, error) { - resp, err := t.Send(ctx, http.MethodGet, "/auth/v1/health", nil) - if err != nil { - return "", err - } - data, err := fetcher.ParseJSON[HealthResponse](resp.Body) - if err != nil { - return "", err - } - if len(data.Version) == 0 { - return "", errors.New(errGotrueVersion) - } - return data.Version, nil -} diff --git a/apps/cli-go/internal/utils/tenant/gotrue_test.go b/apps/cli-go/internal/utils/tenant/gotrue_test.go deleted file mode 100644 index fcc646e0d7..0000000000 --- a/apps/cli-go/internal/utils/tenant/gotrue_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package tenant - -import ( - "context" - "errors" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/pkg/fetcher" -) - -var mockApi = TenantAPI{Fetcher: fetcher.NewFetcher( - "http://127.0.0.1", -)} - -func TestGotrueVersion(t *testing.T) { - t.Run("gets gotrue version", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/auth/v1/health"). - Reply(http.StatusOK). - JSON(HealthResponse{Version: "v2.92.1"}) - // Run test - version, err := mockApi.GetGotrueVersion(context.Background()) - // Check error - assert.NoError(t, err) - assert.Equal(t, "v2.92.1", version) - }) - - t.Run("throws error on network error", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/auth/v1/health"). - ReplyError(errors.New("network error")) - // Run test - version, err := mockApi.GetGotrueVersion(context.Background()) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, version) - }) - - t.Run("throws error on missing version", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/auth/v1/health"). - Reply(http.StatusOK). - JSON(HealthResponse{}) - // Run test - version, err := mockApi.GetGotrueVersion(context.Background()) - // Check error - assert.ErrorIs(t, err, errGotrueVersion) - assert.Empty(t, version) - }) -} diff --git a/apps/cli-go/internal/utils/tenant/postgrest.go b/apps/cli-go/internal/utils/tenant/postgrest.go deleted file mode 100644 index cb8c96d28b..0000000000 --- a/apps/cli-go/internal/utils/tenant/postgrest.go +++ /dev/null @@ -1,39 +0,0 @@ -package tenant - -import ( - "context" - "net/http" - "strings" - - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/fetcher" -) - -var errPostgrestVersion = errors.New("PostgREST version not found.") - -type SwaggerInfo struct { - Title string `json:"title"` - Description string `json:"description"` - Version string `json:"version"` -} - -type SwaggerResponse struct { - Swagger string `json:"swagger"` - Info SwaggerInfo `json:"info"` -} - -func (t *TenantAPI) GetPostgrestVersion(ctx context.Context) (string, error) { - resp, err := t.Send(ctx, http.MethodGet, "/rest/v1/", nil) - if err != nil { - return "", err - } - data, err := fetcher.ParseJSON[SwaggerResponse](resp.Body) - if err != nil { - return "", err - } - if len(data.Info.Version) == 0 { - return "", errors.New(errPostgrestVersion) - } - parts := strings.Fields(data.Info.Version) - return "v" + parts[0], nil -} diff --git a/apps/cli-go/internal/utils/tenant/postgrest_test.go b/apps/cli-go/internal/utils/tenant/postgrest_test.go deleted file mode 100644 index 2be44db441..0000000000 --- a/apps/cli-go/internal/utils/tenant/postgrest_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package tenant - -import ( - "context" - "errors" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" -) - -func TestPostgrestVersion(t *testing.T) { - t.Run("appends prefix v", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/rest/v1/"). - Reply(http.StatusOK). - JSON(SwaggerResponse{Info: SwaggerInfo{Version: "11.1.0"}}) - // Run test - version, err := mockApi.GetPostgrestVersion(context.Background()) - // Check error - assert.NoError(t, err) - assert.Equal(t, "v11.1.0", version) - }) - - t.Run("ignores commit hash", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/rest/v1/"). - Reply(http.StatusOK). - JSON(SwaggerResponse{Info: SwaggerInfo{Version: "11.2.0 (c820efb)"}}) - // Run test - version, err := mockApi.GetPostgrestVersion(context.Background()) - // Check error - assert.NoError(t, err) - assert.Equal(t, "v11.2.0", version) - }) - - t.Run("throws error on network error", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/rest/v1/"). - ReplyError(errors.New("network error")) - // Run test - version, err := mockApi.GetPostgrestVersion(context.Background()) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, version) - }) - - t.Run("throws error on missing version", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/rest/v1/"). - Reply(http.StatusOK). - JSON(SwaggerResponse{}) - // Run test - version, err := mockApi.GetPostgrestVersion(context.Background()) - // Check error - assert.ErrorIs(t, err, errPostgrestVersion) - assert.Empty(t, version) - }) -} diff --git a/apps/cli-go/internal/utils/tenant/storage.go b/apps/cli-go/internal/utils/tenant/storage.go deleted file mode 100644 index 02d1490fc4..0000000000 --- a/apps/cli-go/internal/utils/tenant/storage.go +++ /dev/null @@ -1,27 +0,0 @@ -package tenant - -import ( - "context" - "io" - "net/http" - - "github.com/go-errors/errors" -) - -var errStorageVersion = errors.New("Storage version not found.") - -func (t *TenantAPI) GetStorageVersion(ctx context.Context) (string, error) { - resp, err := t.Send(ctx, http.MethodGet, "/storage/v1/version", nil) - if err != nil { - return "", err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", errors.Errorf("failed to read response body: %w", err) - } - if len(data) == 0 || string(data) == "0.0.0" { - return "", errors.New(errStorageVersion) - } - return "v" + string(data), nil -} diff --git a/apps/cli-go/internal/utils/tenant/storage_test.go b/apps/cli-go/internal/utils/tenant/storage_test.go deleted file mode 100644 index f3c0545b37..0000000000 --- a/apps/cli-go/internal/utils/tenant/storage_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package tenant - -import ( - "context" - "errors" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" -) - -func TestStorageVersion(t *testing.T) { - t.Run("appends prefix v", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/storage/v1/version"). - Reply(http.StatusOK). - BodyString("0.40.4") - // Run test - version, err := mockApi.GetStorageVersion(context.Background()) - // Check error - assert.NoError(t, err) - assert.Equal(t, "v0.40.4", version) - }) - - t.Run("throws error on network error", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/storage/v1/version"). - ReplyError(errors.New("network error")) - // Run test - version, err := mockApi.GetStorageVersion(context.Background()) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, version) - }) - - t.Run("throws error on missing version", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Get("/storage/v1/version"). - Reply(http.StatusOK). - BodyString("0.0.0") - // Run test - version, err := mockApi.GetStorageVersion(context.Background()) - // Check error - assert.ErrorIs(t, err, errStorageVersion) - assert.Empty(t, version) - }) -} diff --git a/apps/cli-go/internal/utils/testdata/invalid.json b/apps/cli-go/internal/utils/testdata/invalid.json deleted file mode 100644 index 4182e56951..0000000000 --- a/apps/cli-go/internal/utils/testdata/invalid.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "supabase", - "api_url": "api.supabase.com", - "dashboard_url": "https://supabase.com/dashboard", - "project_host": "https://supabase.co" -} diff --git a/apps/cli-go/internal/utils/testdata/malformed.json b/apps/cli-go/internal/utils/testdata/malformed.json deleted file mode 100644 index 55a4e3c2a8..0000000000 --- a/apps/cli-go/internal/utils/testdata/malformed.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "supabase", - "test_url": "https://api.supabase.com" -} diff --git a/apps/cli-go/internal/utils/testdata/profile.json b/apps/cli-go/internal/utils/testdata/profile.json deleted file mode 100644 index 66ba4ae450..0000000000 --- a/apps/cli-go/internal/utils/testdata/profile.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "supabase", - "api_url": "https://api.supabase.com", - "dashboard_url": "https://supabase.com/dashboard", - "project_host": "supabase.co" -} diff --git a/apps/cli-go/legacy/branch/create/create.go b/apps/cli-go/legacy/branch/create/create.go deleted file mode 100644 index 5501283763..0000000000 --- a/apps/cli-go/legacy/branch/create/create.go +++ /dev/null @@ -1,103 +0,0 @@ -package create - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/pkg/stdcopy" - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" -) - -var ( - //go:embed templates/clone.sh - cloneScript string -) - -func Run(branch string, fsys afero.Fs) error { - if err := flags.LoadConfig(fsys); err != nil { - return err - } - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - if err := assertNewBranchIsValid(branchPath, fsys); err != nil { - return nil - } - - var ctx = context.Background() - if err := createBranch(ctx, branch); err != nil { - return err - } - - if err := fsys.MkdirAll(branchPath, 0755); err != nil { - return err - } - - fmt.Println("Created branch " + utils.Aqua(branch) + ".") - return nil -} - -func assertNewBranchIsValid(branchPath string, fsys afero.Fs) error { - branch := filepath.Base(branchPath) - - if utils.IsBranchNameReserved(branch) { - return errors.New("Cannot create branch " + utils.Aqua(branch) + ": branch name is reserved.") - } - - if !utils.BranchNamePattern.MatchString(branch) { - return errors.New("Branch name " + utils.Aqua(branch) + " is invalid. Must match [0-9A-Za-z_-]+.") - } - - if _, err := afero.ReadDir(fsys, branchPath); errors.Is(err, os.ErrNotExist) { - // skip - } else if err != nil { - return err - } else { - return errors.New("Branch " + utils.Aqua(branch) + " already exists.") - } - - return nil -} - -func createBranch(ctx context.Context, branch string) error { - exec, err := utils.Docker.ContainerExecCreate(ctx, utils.DbId, container.ExecOptions{ - Cmd: []string{"/bin/bash", "-c", cloneScript}, - Env: []string{"DB_NAME=" + branch}, - AttachStderr: true, - AttachStdout: true, - }) - if err != nil { - return err - } - // Read exec output - resp, err := utils.Docker.ContainerExecAttach(ctx, exec.ID, container.ExecStartOptions{}) - if err != nil { - return err - } - defer resp.Close() - // Capture error details - var errBuf bytes.Buffer - if _, err := stdcopy.StdCopy(io.Discard, &errBuf, resp.Reader); err != nil { - return err - } - // Get the exit code - iresp, err := utils.Docker.ContainerExecInspect(ctx, exec.ID) - if err != nil { - return err - } - if iresp.ExitCode > 0 { - return errors.New("Error creating branch: " + errBuf.String()) - } - return nil -} diff --git a/apps/cli-go/legacy/branch/create/create_test.go b/apps/cli-go/legacy/branch/create/create_test.go deleted file mode 100644 index 66b919ecbd..0000000000 --- a/apps/cli-go/legacy/branch/create/create_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package create - -import ( - "context" - "net/http" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" -) - -func TestBranchValidation(t *testing.T) { - t.Run("branch name is valid", func(t *testing.T) { - assert.NoError(t, assertNewBranchIsValid("test-branch", afero.NewMemMapFs())) - }) - - t.Run("branch name is reserved", func(t *testing.T) { - assert.Error(t, assertNewBranchIsValid("main", afero.NewMemMapFs())) - }) - - t.Run("branch name is invalid", func(t *testing.T) { - assert.Error(t, assertNewBranchIsValid("@", afero.NewMemMapFs())) - }) - - t.Run("branch not a directory", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := "/supabase/.branches/test-branch" - _, err := fsys.Create(path) - require.NoError(t, err) - // Run test - assert.Error(t, assertNewBranchIsValid(path, fsys)) - }) - - t.Run("branch already exists", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := "/supabase/.branches/test-branch" - require.NoError(t, fsys.MkdirAll(path, 0755)) - // Run test - assert.Error(t, assertNewBranchIsValid(path, fsys)) - }) -} - -func TestBranchCreation(t *testing.T) { - utils.DbId = "test-db" - - t.Run("docker exec failure", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/exec"). - Reply(http.StatusServiceUnavailable) - // Run test - err := createBranch(context.Background(), "test-branch") - // Validate api - assert.ErrorContains(t, err, "request returned 503 Service Unavailable for API route and version") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("docker attach failure", func(t *testing.T) { - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/exec"). - Reply(http.StatusCreated). - JSON(container.InspectResponse{}) - // Run test - err := createBranch(context.Background(), "test-branch") - // Validate api - assert.ErrorContains(t, err, "unable to upgrade to tcp, received 404") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestCreateCommand(t *testing.T) { - const branch = "test-branch" - - t.Run("throws error on missing config", func(t *testing.T) { - assert.Error(t, Run(branch, afero.NewMemMapFs())) - }) - - t.Run("throws error on stopped db", func(t *testing.T) { - // Setup in-memory fs - fsys := &afero.MemMapFs{} - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusNotFound) - // Run test - err := Run(branch, fsys) - // Validate api - assert.ErrorIs(t, err, utils.ErrNotRunning) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/legacy/branch/create/templates/clone.sh b/apps/cli-go/legacy/branch/create/templates/clone.sh deleted file mode 100755 index ac8d6ccaa2..0000000000 --- a/apps/cli-go/legacy/branch/create/templates/clone.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -euo pipefail - -createdb --username postgres --host 127.0.0.1 "$DB_NAME" -pg_dump --username postgres --host 127.0.0.1 postgres | psql --username postgres --host 127.0.0.1 "$DB_NAME" diff --git a/apps/cli-go/legacy/branch/delete/delete.go b/apps/cli-go/legacy/branch/delete/delete.go deleted file mode 100644 index ea14cd34d6..0000000000 --- a/apps/cli-go/legacy/branch/delete/delete.go +++ /dev/null @@ -1,90 +0,0 @@ -package delete - -import ( - "bytes" - "context" - "fmt" - "io" - "path/filepath" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/pkg/stdcopy" - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" -) - -func Run(branch string, fsys afero.Fs) error { - if err := flags.LoadConfig(fsys); err != nil { - return err - } - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - - // TODO: update branch history atomically with database - if err := deleteBranchDir(branch, fsys); err != nil { - return err - } - - ctx := context.Background() - if err := deleteBranchPG(ctx, branch); err != nil { - return err - } - - fmt.Println("Deleted branch " + utils.Aqua(branch) + ".") - return nil -} - -func deleteBranchDir(branch string, fsys afero.Fs) error { - if currBranch, _ := utils.GetCurrentBranchFS(fsys); branch == currBranch { - return errors.New("Cannot delete current branch.") - } - - if utils.IsBranchNameReserved(branch) { - return errors.New("Cannot delete branch " + utils.Aqua(branch) + ": branch name is reserved.") - } - - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - if _, err := afero.ReadDir(fsys, branchPath); err != nil { - return errors.New("Branch " + utils.Aqua(branch) + " does not exist.") - } - - if err := fsys.RemoveAll(branchPath); err != nil { - return errors.Errorf("Failed deleting branch %s: %w", utils.Aqua(branch), err) - } - - return nil -} - -func deleteBranchPG(ctx context.Context, branch string) error { - exec, err := utils.Docker.ContainerExecCreate(ctx, utils.DbId, container.ExecOptions{ - Cmd: []string{"dropdb", "--username", "postgres", "--host", "127.0.0.1", branch}, - AttachStderr: true, - AttachStdout: true, - }) - if err != nil { - return err - } - // Read exec output - resp, err := utils.Docker.ContainerExecAttach(ctx, exec.ID, container.ExecStartOptions{}) - if err != nil { - return err - } - defer resp.Close() - // Capture error details - var errBuf bytes.Buffer - if _, err := stdcopy.StdCopy(io.Discard, &errBuf, resp.Reader); err != nil { - return err - } - // Get the exit code - iresp, err := utils.Docker.ContainerExecInspect(ctx, exec.ID) - if err != nil { - return err - } - if iresp.ExitCode > 0 { - return errors.New("Error deleting branch: " + errBuf.String()) - } - return nil -} diff --git a/apps/cli-go/legacy/branch/delete/delete_test.go b/apps/cli-go/legacy/branch/delete/delete_test.go deleted file mode 100644 index 76957e2460..0000000000 --- a/apps/cli-go/legacy/branch/delete/delete_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package delete - -import ( - "net/http" - "path/filepath" - "testing" - - "github.com/h2non/gock" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" -) - -func TestBranchDir(t *testing.T) { - t.Run("removes a branch directory", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(filepath.Dir(utils.CurrBranchPath), "test-branch") - require.NoError(t, fsys.Mkdir(path, 0755)) - // Run test - assert.NoError(t, deleteBranchDir("test-branch", fsys)) - // Validate removal - exists, err := afero.Exists(fsys, path) - assert.NoError(t, err) - assert.False(t, exists) - }) - - t.Run("branch is current", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.CurrBranchPath, []byte("main"), 0644)) - // Run test - assert.Error(t, deleteBranchDir("main", fsys)) - }) - - t.Run("branch is reserved", func(t *testing.T) { - assert.Error(t, deleteBranchDir("main", afero.NewMemMapFs())) - }) - - t.Run("branch does not exist", func(t *testing.T) { - assert.Error(t, deleteBranchDir("test-branch", afero.NewMemMapFs())) - }) - - t.Run("branch permission denied", func(t *testing.T) { - // Setup read-only fs - fsys := afero.NewMemMapFs() - path := filepath.Join(filepath.Dir(utils.CurrBranchPath), "test-branch") - require.NoError(t, fsys.Mkdir(path, 0755)) - // Run test - assert.Error(t, deleteBranchDir("test-branch", afero.NewReadOnlyFs(fsys))) - }) -} - -func TestDeleteCommand(t *testing.T) { - const branch = "test-branch" - - t.Run("throws error on missing config", func(t *testing.T) { - assert.Error(t, Run(branch, afero.NewMemMapFs())) - }) - - t.Run("throws error on stopped db", func(t *testing.T) { - // Setup in-memory fs - fsys := &afero.MemMapFs{} - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusNotFound) - // Run test - err := Run(branch, fsys) - // Validate api - assert.ErrorIs(t, err, utils.ErrNotRunning) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/legacy/branch/list/list.go b/apps/cli-go/legacy/branch/list/list.go deleted file mode 100644 index b1037ac1b7..0000000000 --- a/apps/cli-go/legacy/branch/list/list.go +++ /dev/null @@ -1,36 +0,0 @@ -package list - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" -) - -func Run(fsys afero.Fs, out io.Writer) error { - branches, err := afero.ReadDir(fsys, filepath.Dir(utils.CurrBranchPath)) - if errors.Is(err, os.ErrNotExist) { - return nil - } else if err != nil { - return err - } - - currBranch, _ := utils.GetCurrentBranchFS(fsys) - for _, branch := range branches { - if branch.Name() == filepath.Base(utils.CurrBranchPath) { - continue - } - - if branch.Name() == currBranch { - fmt.Fprintln(out, "*", branch.Name()) - } else { - fmt.Fprintln(out, " ", branch.Name()) - } - } - - return nil -} diff --git a/apps/cli-go/legacy/branch/list/list_test.go b/apps/cli-go/legacy/branch/list/list_test.go deleted file mode 100644 index 24e34d5e53..0000000000 --- a/apps/cli-go/legacy/branch/list/list_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package list - -import ( - "bytes" - "io" - "path/filepath" - "strings" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestListCommand(t *testing.T) { - t.Run("lists all branches", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.CurrBranchPath, []byte("main"), 0644)) - base := filepath.Dir(utils.CurrBranchPath) - require.NoError(t, fsys.Mkdir(filepath.Join(base, "main"), 0755)) - require.NoError(t, fsys.Mkdir(filepath.Join(base, "test"), 0755)) - // Run test - var out bytes.Buffer - require.NoError(t, Run(fsys, &out)) - // Validate output - lines := strings.Split(out.String(), "\n") - assert.ElementsMatch(t, []string{ - "* main", - " test", - "", - }, lines) - }) - - t.Run("lists without current branch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - base := filepath.Dir(utils.CurrBranchPath) - require.NoError(t, fsys.Mkdir(filepath.Join(base, "main"), 0755)) - require.NoError(t, fsys.Mkdir(filepath.Join(base, "test"), 0755)) - // Run test - var out bytes.Buffer - require.NoError(t, Run(fsys, &out)) - // Validate output - lines := strings.Split(out.String(), "\n") - assert.ElementsMatch(t, []string{ - " main", - " test", - "", - }, lines) - }) - - t.Run("lists uninitialized branch", func(t *testing.T) { - require.NoError(t, Run(afero.NewMemMapFs(), io.Discard)) - }) - - t.Run("throws error on unreadable directory", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - _, err := fsys.Create(filepath.Dir(utils.CurrBranchPath)) - require.NoError(t, err) - // Run test - require.Error(t, Run(fsys, io.Discard)) - }) -} diff --git a/apps/cli-go/legacy/branch/switch_/switch_.go b/apps/cli-go/legacy/branch/switch_/switch_.go deleted file mode 100644 index d11803e884..0000000000 --- a/apps/cli-go/legacy/branch/switch_/switch_.go +++ /dev/null @@ -1,88 +0,0 @@ -package switch_ - -import ( - "context" - "fmt" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/reset" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" -) - -func Run(ctx context.Context, target string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - // 1. Sanity checks - { - if err := flags.LoadConfig(fsys); err != nil { - return err - } - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - if target != "main" && utils.IsBranchNameReserved(target) { - return errors.New("Cannot switch branch " + utils.Aqua(target) + ": branch name is reserved.") - } - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), target) - if _, err := fsys.Stat(branchPath); errors.Is(err, os.ErrNotExist) { - return errors.New("Branch " + utils.Aqua(target) + " does not exist.") - } else if err != nil { - return err - } - } - - // 2. Check current branch - currBranch, err := utils.GetCurrentBranchFS(fsys) - if err != nil { - // Assume we are on main branch - currBranch = "main" - } - - // 3. Switch Postgres database - if currBranch == target { - fmt.Println("Already on branch " + utils.Aqua(target) + ".") - } else if err := switchDatabase(ctx, currBranch, target, options...); err != nil { - return errors.New("Error switching to branch " + utils.Aqua(target) + ": " + err.Error()) - } else { - fmt.Println("Switched to branch " + utils.Aqua(target) + ".") - } - - // 4. Update current branch - if err := afero.WriteFile(fsys, utils.CurrBranchPath, []byte(target), 0644); err != nil { - return errors.New("Unable to update local branch file. Fix by running: echo '" + target + "' > " + utils.CurrBranchPath) - } - return nil -} - -func switchDatabase(ctx context.Context, source, target string, options ...func(*pgx.ConnConfig)) error { - conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{Database: "template1"}, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if err := reset.DisconnectClients(ctx, conn); err != nil { - return err - } - defer func() { - if err := reset.RestartDatabase(context.Background(), os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, "Failed to restart database:", err) - } - }() - backup := "ALTER DATABASE postgres RENAME TO " + source + ";" - if _, err := conn.Exec(ctx, backup); err != nil { - return err - } - rename := "ALTER DATABASE " + target + " RENAME TO postgres;" - if _, err := conn.Exec(ctx, rename); err != nil { - rollback := "ALTER DATABASE " + source + " RENAME TO postgres;" - if _, err := conn.Exec(ctx, rollback); err != nil { - fmt.Fprintln(os.Stderr, "Failed to rollback database:", err) - } - return err - } - return nil -} diff --git a/apps/cli-go/legacy/branch/switch_/switch__test.go b/apps/cli-go/legacy/branch/switch_/switch__test.go deleted file mode 100644 index f0e51d521e..0000000000 --- a/apps/cli-go/legacy/branch/switch_/switch__test.go +++ /dev/null @@ -1,290 +0,0 @@ -package switch_ - -import ( - "context" - "net/http" - "path/filepath" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/h2non/gock" - "github.com/jackc/pgerrcode" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/db/reset" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestSwitchCommand(t *testing.T) { - t.Run("switches local branch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup target branch - branch := "target" - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - require.NoError(t, fsys.Mkdir(branchPath, 0755)) - require.NoError(t, afero.WriteFile(fsys, utils.CurrBranchPath, []byte("main"), 0644)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusServiceUnavailable) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query(reset.TERMINATE_BACKENDS). - Reply("SELECT 1"). - Query(reset.COUNT_REPLICATION_SLOTS). - Reply("SELECT 1", []any{0}). - Query("ALTER DATABASE postgres RENAME TO main;"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE " + branch + " RENAME TO postgres;"). - Reply("ALTER DATABASE") - // Run test - assert.NoError(t, Run(context.Background(), branch, fsys, conn.Intercept)) - // Validate output - assert.Empty(t, apitest.ListUnmatchedRequests()) - contents, err := afero.ReadFile(fsys, utils.CurrBranchPath) - assert.NoError(t, err) - assert.Equal(t, []byte(branch), contents) - }) - - t.Run("throws error on malformed config", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) - // Run test - err := Run(context.Background(), "target", fsys) - // Check error - assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") - }) - - t.Run("throws error on missing database", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusNotFound) - // Run test - err := Run(context.Background(), "target", fsys) - // Check error - assert.ErrorIs(t, err, utils.ErrNotRunning) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on reserved branch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Run test - err := Run(context.Background(), "postgres", fsys) - // Check error - assert.ErrorContains(t, err, "branch name is reserved.") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on missing branch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Run test - err := Run(context.Background(), "main", fsys) - // Check error - assert.ErrorContains(t, err, "Branch main does not exist.") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("noop on current branch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Setup target branch - branch := "main" - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - require.NoError(t, fsys.Mkdir(branchPath, 0755)) - // Run test - assert.NoError(t, Run(context.Background(), branch, fsys)) - // Check error - assert.Empty(t, apitest.ListUnmatchedRequests()) - contents, err := afero.ReadFile(fsys, utils.CurrBranchPath) - assert.NoError(t, err) - assert.Equal(t, []byte(branch), contents) - }) - - t.Run("throws error on failure to switch", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Setup target branch - branch := "target" - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - require.NoError(t, fsys.Mkdir(branchPath, 0755)) - // Setup mock postgres - conn := pgtest.NewConn() - // Run test - err := Run(context.Background(), branch, fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, "Error switching to branch target") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to write", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - require.NoError(t, utils.WriteConfig(fsys, false)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers"). - Reply(http.StatusOK). - JSON(container.InspectResponse{}) - // Setup target branch - branch := "main" - branchPath := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch) - require.NoError(t, fsys.Mkdir(branchPath, 0755)) - // Run test - err := Run(context.Background(), branch, afero.NewReadOnlyFs(fsys)) - // Check error - assert.ErrorContains(t, err, "Unable to update local branch file.") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestSwitchDatabase(t *testing.T) { - t.Run("throws error on failure to connect", func(t *testing.T) { - // Setup invalid port - utils.Config.Db.Port = 0 - // Run test - err := switchDatabase(context.Background(), "main", "target") - // Check error - assert.ErrorContains(t, err, "invalid port") - }) - - t.Run("throws error on failure to disconnect", func(t *testing.T) { - // Setup valid config - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - ReplyError(pgerrcode.InvalidParameterValue, `cannot disallow connections for current database`). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Query(reset.TERMINATE_BACKENDS) - // Run test - err := switchDatabase(context.Background(), "main", "target", conn.Intercept) - // Check error - assert.ErrorContains(t, err, pgerrcode.InvalidParameterValue) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to backup", func(t *testing.T) { - // Setup valid config - utils.DbId = "test-switch" - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query(reset.TERMINATE_BACKENDS). - Reply("SELECT 1"). - Query(reset.COUNT_REPLICATION_SLOTS). - Reply("SELECT 1", []any{0}). - Query("ALTER DATABASE postgres RENAME TO main;"). - ReplyError(pgerrcode.DuplicateDatabase, `database "main" already exists`) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusServiceUnavailable) - // Run test - err := switchDatabase(context.Background(), "main", "target", conn.Intercept) - // Check error - assert.ErrorContains(t, err, pgerrcode.DuplicateDatabase) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on failure to rename", func(t *testing.T) { - // Setup valid config - utils.DbId = "test-switch" - utils.Config.Db.Port = 54322 - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Reply("ALTER DATABASE"). - Query(reset.TERMINATE_BACKENDS). - Reply("SELECT 1"). - Query(reset.COUNT_REPLICATION_SLOTS). - Reply("SELECT 1", []any{0}). - Query("ALTER DATABASE postgres RENAME TO main;"). - Reply("ALTER DATABASE"). - Query("ALTER DATABASE target RENAME TO postgres;"). - ReplyError(pgerrcode.InvalidCatalogName, `database "target" does not exist`). - // Attempt to rollback - Query("ALTER DATABASE main RENAME TO postgres;"). - ReplyError(pgerrcode.DuplicateDatabase, `database "postgres" already exists`) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/restart"). - Reply(http.StatusServiceUnavailable) - // Run test - err := switchDatabase(context.Background(), "main", "target", conn.Intercept) - // Check error - assert.ErrorContains(t, err, pgerrcode.InvalidCatalogName) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/legacy/keys/keys.go b/apps/cli-go/legacy/keys/keys.go deleted file mode 100644 index 560a4ce917..0000000000 --- a/apps/cli-go/legacy/keys/keys.go +++ /dev/null @@ -1,75 +0,0 @@ -package keys - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "strings" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -type CustomName struct { - DbHost string `env:"db.host,default=NEXT_PUBLIC_SUPABASE_URL"` - DbPassword string `env:"db.password,default=SUPABASE_DB_PASSWORD"` - JWTSecret string `env:"db.password,default=SUPABASE_AUTH_JWT_SECRET"` - AnonKey string `env:"auth.anon_key,default=SUPABASE_AUTH_ANON_KEY"` - ServiceRoleKey string `env:"auth.service_role_key,default=SUPABASE_AUTH_SERVICE_ROLE_KEY"` -} - -func Run(ctx context.Context, projectRef, format string, names CustomName, fsys afero.Fs) error { - branch := utils.GetGitBranch(fsys) - if err := GenerateSecrets(ctx, projectRef, branch, fsys); err != nil { - return err - } - return utils.EncodeOutput(format, os.Stdout, map[string]string{ - names.DbHost: fmt.Sprintf("%s-%s.fly.dev", projectRef, branch), - names.DbPassword: utils.Config.Db.Password, - names.JWTSecret: utils.Config.Auth.JwtSecret.Value, - names.AnonKey: utils.Config.Auth.AnonKey.Value, - names.ServiceRoleKey: utils.Config.Auth.ServiceRoleKey.Value, - }) -} - -func GenerateSecrets(ctx context.Context, projectRef, branch string, fsys afero.Fs) error { - // Load JWT secret from api - resp, err := utils.GetSupabase().V1GetPostgrestServiceConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to get postgrest config: %w", err) - } - if resp.JSON200 == nil { - return errors.New("Unexpected error retrieving JWT secret: " + string(resp.Body)) - } - utils.Config.Auth.JwtSecret.Value = *resp.JSON200.JwtSecret - // Generate database password - key := strings.Join([]string{ - projectRef, - utils.Config.Auth.JwtSecret.Value, - branch, - }, ":") - hash := sha256.Sum256([]byte(key)) - utils.Config.Db.Password = hex.EncodeToString(hash[:]) - // Generate JWT tokens - anonToken := config.CustomClaims{ - Issuer: "supabase", - Ref: projectRef, - Role: "anon", - }.NewToken() - if utils.Config.Auth.AnonKey.Value, err = anonToken.SignedString([]byte(utils.Config.Auth.JwtSecret.Value)); err != nil { - return errors.Errorf("failed to sign anon key: %w", err) - } - serviceToken := config.CustomClaims{ - Issuer: "supabase", - Ref: projectRef, - Role: "service_role", - }.NewToken() - if utils.Config.Auth.ServiceRoleKey.Value, err = serviceToken.SignedString([]byte(utils.Config.Auth.JwtSecret.Value)); err != nil { - return errors.Errorf("failed to sign service_role key: %w", err) - } - return nil -} diff --git a/apps/cli-go/main.go b/apps/cli-go/main.go deleted file mode 100644 index bcc8fa3db1..0000000000 --- a/apps/cli-go/main.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "github.com/supabase/cli/cmd" -) - -// Codegen reads the committed api/v1-openapi.yaml snapshot instead of fetching -// the live upstream spec, so `go generate` is reproducible offline and the -// Codegen CI check only fails on a PR that leaves pkg/api out of sync with the -// snapshot. The API Sync workflow refreshes the snapshot and pkg/api together. -//go:generate go tool oapi-codegen -config pkg/api/types.cfg.yaml api/v1-openapi.yaml -//go:generate go tool oapi-codegen -config pkg/api/client.cfg.yaml api/v1-openapi.yaml - -func main() { - cmd.Execute() -} diff --git a/apps/cli-go/package.json b/apps/cli-go/package.json deleted file mode 100644 index 197015c913..0000000000 --- a/apps/cli-go/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@supabase/cli-go", - "private": true, - "scripts": { - "build": "go build -o supabase-go .", - "lint:check": "golangci-lint run --timeout 5m", - "lint:fix": "golangci-lint run --fix", - "test:unit": "pnpm exec turbo run @supabase/cli-go#test:unit:run --", - "test:unit:run": "go test ./..." - } -} diff --git a/apps/cli-go/pkg/api/client.cfg.yaml b/apps/cli-go/pkg/api/client.cfg.yaml deleted file mode 100755 index f06e6cad13..0000000000 --- a/apps/cli-go/pkg/api/client.cfg.yaml +++ /dev/null @@ -1,7 +0,0 @@ -package: api -generate: - client: true -output-options: - overlay: - path: api/overlay.yaml -output: pkg/api/client.gen.go diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go deleted file mode 100644 index 98ea157712..0000000000 --- a/apps/cli-go/pkg/api/client.gen.go +++ /dev/null @@ -1,23229 +0,0 @@ -// Package api provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/oapi-codegen/runtime" - openapi_types "github.com/oapi-codegen/runtime/types" -) - -// RequestEditorFn is the function signature for the RequestEditor callback function -type RequestEditorFn func(ctx context.Context, req *http.Request) error - -// Doer performs HTTP requests. -// -// The standard http.Client implements this interface. -type HttpRequestDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -// Client which conforms to the OpenAPI3 specification for this service. -type Client struct { - // The endpoint of the server conforming to this interface, with scheme, - // https://api.deepmap.com for example. This can contain a path relative - // to the server, such as https://api.deepmap.com/dev-test, and all the - // paths in the swagger spec will be appended to the server. - Server string - - // Doer for performing requests, typically a *http.Client with any - // customized settings, such as certificate chains. - Client HttpRequestDoer - - // A list of callbacks for modifying requests which are generated before sending over - // the network. - RequestEditors []RequestEditorFn -} - -// ClientOption allows setting custom parameters during construction -type ClientOption func(*Client) error - -// Creates a new Client, with reasonable defaults -func NewClient(server string, opts ...ClientOption) (*Client, error) { - // create a client with sane default values - client := Client{ - Server: server, - } - // mutate client and add all optional params - for _, o := range opts { - if err := o(&client); err != nil { - return nil, err - } - } - // ensure the server URL always has a trailing slash - if !strings.HasSuffix(client.Server, "/") { - client.Server += "/" - } - // create httpClient, if not already present - if client.Client == nil { - client.Client = &http.Client{} - } - return &client, nil -} - -// WithHTTPClient allows overriding the default Doer, which is -// automatically created using http.Client. This is useful for tests. -func WithHTTPClient(doer HttpRequestDoer) ClientOption { - return func(c *Client) error { - c.Client = doer - return nil - } -} - -// WithRequestEditorFn allows setting up a callback function, which will be -// called right before sending the request. This can be used to mutate the request. -func WithRequestEditorFn(fn RequestEditorFn) ClientOption { - return func(c *Client) error { - c.RequestEditors = append(c.RequestEditors, fn) - return nil - } -} - -// The interface specification for the client above. -type ClientInterface interface { - // V1DeleteABranch request - V1DeleteABranch(ctx context.Context, branchIdOrRef string, params *V1DeleteABranchParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetABranchConfig request - V1GetABranchConfig(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateABranchConfigWithBody request with any body - V1UpdateABranchConfigWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateABranchConfig(ctx context.Context, branchIdOrRef string, body V1UpdateABranchConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DiffABranch request - V1DiffABranch(ctx context.Context, branchIdOrRef string, params *V1DiffABranchParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1MergeABranchWithBody request with any body - V1MergeABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1MergeABranch(ctx context.Context, branchIdOrRef string, body V1MergeABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1PushABranchWithBody request with any body - V1PushABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1PushABranch(ctx context.Context, branchIdOrRef string, body V1PushABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ResetABranchWithBody request with any body - V1ResetABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ResetABranch(ctx context.Context, branchIdOrRef string, body V1ResetABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RestoreABranch request - V1RestoreABranch(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1AuthorizeUser request - V1AuthorizeUser(ctx context.Context, params *V1AuthorizeUserParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1OauthAuthorizeProjectClaim request - V1OauthAuthorizeProjectClaim(ctx context.Context, params *V1OauthAuthorizeProjectClaimParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RevokeTokenWithBody request with any body - V1RevokeTokenWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1RevokeToken(ctx context.Context, body V1RevokeTokenJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ExchangeOauthTokenWithBody request with any body - V1ExchangeOauthTokenWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ExchangeOauthTokenWithFormdataBody(ctx context.Context, body V1ExchangeOauthTokenFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllOrganizations request - V1ListAllOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateAnOrganizationWithBody request with any body - V1CreateAnOrganizationWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateAnOrganization(ctx context.Context, body V1CreateAnOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAnOrganization request - V1GetAnOrganization(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetOrganizationEntitlements request - V1GetOrganizationEntitlements(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListOrganizationMembers request - V1ListOrganizationMembers(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetOrganizationProjectClaim request - V1GetOrganizationProjectClaim(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ClaimProjectForOrganization request - V1ClaimProjectForOrganization(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAllProjectsForOrganization request - V1GetAllProjectsForOrganization(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProfile request - V1GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllProjects request - V1ListAllProjects(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateAProjectWithBody request with any body - V1CreateAProjectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateAProject(ctx context.Context, body V1CreateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAvailableRegions request - V1GetAvailableRegions(ctx context.Context, params *V1GetAvailableRegionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteAProject request - V1DeleteAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProject request - V1GetProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateAProjectWithBody request with any body - V1UpdateAProjectWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateAProject(ctx context.Context, ref string, body V1UpdateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListActionRuns request - V1ListActionRuns(ctx context.Context, ref string, params *V1ListActionRunsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CountActionRuns request - V1CountActionRuns(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetActionRun request - V1GetActionRun(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetActionRunLogs request - V1GetActionRunLogs(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateActionRunStatusWithBody request with any body - V1UpdateActionRunStatusWithBody(ctx context.Context, ref string, runId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateActionRunStatus(ctx context.Context, ref string, runId string, body V1UpdateActionRunStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPerformanceAdvisors request - V1GetPerformanceAdvisors(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetSecurityAdvisors request - V1GetSecurityAdvisors(ctx context.Context, ref string, params *V1GetSecurityAdvisorsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectFunctionCombinedStats request - V1GetProjectFunctionCombinedStats(ctx context.Context, ref string, params *V1GetProjectFunctionCombinedStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectLogs request - V1GetProjectLogs(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectLogsAll request - V1GetProjectLogsAll(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ScrapeProjectMetrics request - V1ScrapeProjectMetrics(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectUsageApiCount request - V1GetProjectUsageApiCount(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectUsageRequestCount request - V1GetProjectUsageRequestCount(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectApiKeys request - V1GetProjectApiKeys(ctx context.Context, ref string, params *V1GetProjectApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateProjectApiKeyWithBody request with any body - V1CreateProjectApiKeyWithBody(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateProjectApiKey(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, body V1CreateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectLegacyApiKeys request - V1GetProjectLegacyApiKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateProjectLegacyApiKeys request - V1UpdateProjectLegacyApiKeys(ctx context.Context, ref string, params *V1UpdateProjectLegacyApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteProjectApiKey request - V1DeleteProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1DeleteProjectApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectApiKey request - V1GetProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1GetProjectApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateProjectApiKeyWithBody request with any body - V1UpdateProjectApiKeyWithBody(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, body V1UpdateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListProjectAddons request - V1ListProjectAddons(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ApplyProjectAddonWithBody request with any body - V1ApplyProjectAddonWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ApplyProjectAddon(ctx context.Context, ref string, body V1ApplyProjectAddonJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RemoveProjectAddon request - V1RemoveProjectAddon(ctx context.Context, ref string, addonVariant struct { - union json.RawMessage - }, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DisablePreviewBranching request - V1DisablePreviewBranching(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllBranches request - V1ListAllBranches(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateABranchWithBody request with any body - V1CreateABranchWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateABranch(ctx context.Context, ref string, body V1CreateABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetABranch request - V1GetABranch(ctx context.Context, ref string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteProjectClaimToken request - V1DeleteProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectClaimToken request - V1GetProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateProjectClaimToken request - V1CreateProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteLoginRoles request - V1DeleteLoginRoles(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateLoginRoleWithBody request with any body - V1CreateLoginRoleWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateLoginRole(ctx context.Context, ref string, body V1CreateLoginRoleJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAuthServiceConfig request - V1GetAuthServiceConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateAuthServiceConfigWithBody request with any body - V1UpdateAuthServiceConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateAuthServiceConfig(ctx context.Context, ref string, body V1UpdateAuthServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectSigningKeys request - V1GetProjectSigningKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateProjectSigningKeyWithBody request with any body - V1CreateProjectSigningKeyWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateProjectSigningKey(ctx context.Context, ref string, body V1CreateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetLegacySigningKey request - V1GetLegacySigningKey(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateLegacySigningKey request - V1CreateLegacySigningKey(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RemoveProjectSigningKey request - V1RemoveProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectSigningKey request - V1GetProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateProjectSigningKeyWithBody request with any body - V1UpdateProjectSigningKeyWithBody(ctx context.Context, ref string, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, body V1UpdateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllSsoProvider request - V1ListAllSsoProvider(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateASsoProviderWithBody request with any body - V1CreateASsoProviderWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateASsoProvider(ctx context.Context, ref string, body V1CreateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteASsoProvider request - V1DeleteASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetASsoProvider request - V1GetASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateASsoProviderWithBody request with any body - V1UpdateASsoProviderWithBody(ctx context.Context, ref string, providerId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, body V1UpdateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListProjectTpaIntegrations request - V1ListProjectTpaIntegrations(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateProjectTpaIntegrationWithBody request with any body - V1CreateProjectTpaIntegrationWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateProjectTpaIntegration(ctx context.Context, ref string, body V1CreateProjectTpaIntegrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteProjectTpaIntegration request - V1DeleteProjectTpaIntegration(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectTpaIntegration request - V1GetProjectTpaIntegration(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectPgbouncerConfig request - V1GetProjectPgbouncerConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPoolerConfig request - V1GetPoolerConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdatePoolerConfigWithBody request with any body - V1UpdatePoolerConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdatePoolerConfig(ctx context.Context, ref string, body V1UpdatePoolerConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPostgresConfig request - V1GetPostgresConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdatePostgresConfigWithBody request with any body - V1UpdatePostgresConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdatePostgresConfig(ctx context.Context, ref string, body V1UpdatePostgresConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetDatabaseDisk request - V1GetDatabaseDisk(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ModifyDatabaseDiskWithBody request with any body - V1ModifyDatabaseDiskWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ModifyDatabaseDisk(ctx context.Context, ref string, body V1ModifyDatabaseDiskJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetProjectDiskAutoscaleConfig request - V1GetProjectDiskAutoscaleConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetDiskUtilization request - V1GetDiskUtilization(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetRealtimeConfig request - V1GetRealtimeConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateRealtimeConfigWithBody request with any body - V1UpdateRealtimeConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateRealtimeConfig(ctx context.Context, ref string, body V1UpdateRealtimeConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ShutdownRealtime request - V1ShutdownRealtime(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetStorageConfig request - V1GetStorageConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateStorageConfigWithBody request with any body - V1UpdateStorageConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateStorageConfig(ctx context.Context, ref string, body V1UpdateStorageConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteHostnameConfig request - V1DeleteHostnameConfig(ctx context.Context, ref string, params *V1DeleteHostnameConfigParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetHostnameConfig request - V1GetHostnameConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ActivateCustomHostname request - V1ActivateCustomHostname(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateHostnameConfigWithBody request with any body - V1UpdateHostnameConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateHostnameConfig(ctx context.Context, ref string, body V1UpdateHostnameConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1VerifyDnsConfig request - V1VerifyDnsConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllBackups request - V1ListAllBackups(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RestorePhysicalBackupWithBody request with any body - V1RestorePhysicalBackupWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1RestorePhysicalBackup(ctx context.Context, ref string, body V1RestorePhysicalBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RestorePitrBackupWithBody request with any body - V1RestorePitrBackupWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1RestorePitrBackup(ctx context.Context, ref string, body V1RestorePitrBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetRestorePoint request - V1GetRestorePoint(ctx context.Context, ref string, params *V1GetRestorePointParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateRestorePointWithBody request with any body - V1CreateRestorePointWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateRestorePoint(ctx context.Context, ref string, body V1CreateRestorePointJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetBackupSchedule request - V1GetBackupSchedule(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateBackupScheduleWithBody request with any body - V1UpdateBackupScheduleWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateBackupSchedule(ctx context.Context, ref string, body V1UpdateBackupScheduleJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UndoWithBody request with any body - V1UndoWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1Undo(ctx context.Context, ref string, body V1UndoJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetDatabaseMetadata request - V1GetDatabaseMetadata(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetJitAccess request - V1GetJitAccess(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1AuthorizeJitAccessWithBody request with any body - V1AuthorizeJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1AuthorizeJitAccess(ctx context.Context, ref string, body V1AuthorizeJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateJitAccessWithBody request with any body - V1UpdateJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateJitAccess(ctx context.Context, ref string, body V1UpdateJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1InviteExternalJitAccessWithBody request with any body - V1InviteExternalJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1InviteExternalJitAccess(ctx context.Context, ref string, body V1InviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1AcceptInviteExternalJitAccessWithBody request with any body - V1AcceptInviteExternalJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1AcceptInviteExternalJitAccess(ctx context.Context, ref string, body V1AcceptInviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteInviteExternalJitAccess request - V1DeleteInviteExternalJitAccess(ctx context.Context, ref string, inviteId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListJitAccess request - V1ListJitAccess(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteJitAccess request - V1DeleteJitAccess(ctx context.Context, ref string, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RollbackMigrations request - V1RollbackMigrations(ctx context.Context, ref string, params *V1RollbackMigrationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListMigrationHistory request - V1ListMigrationHistory(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ApplyAMigrationWithBody request with any body - V1ApplyAMigrationWithBody(ctx context.Context, ref string, params *V1ApplyAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ApplyAMigration(ctx context.Context, ref string, params *V1ApplyAMigrationParams, body V1ApplyAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpsertAMigrationWithBody request with any body - V1UpsertAMigrationWithBody(ctx context.Context, ref string, params *V1UpsertAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpsertAMigration(ctx context.Context, ref string, params *V1UpsertAMigrationParams, body V1UpsertAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAMigration request - V1GetAMigration(ctx context.Context, ref string, version string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1PatchAMigrationWithBody request with any body - V1PatchAMigrationWithBody(ctx context.Context, ref string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1PatchAMigration(ctx context.Context, ref string, version string, body V1PatchAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetDatabaseOpenapi request - V1GetDatabaseOpenapi(ctx context.Context, ref string, params *V1GetDatabaseOpenapiParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateDatabasePasswordWithBody request with any body - V1UpdateDatabasePasswordWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateDatabasePassword(ctx context.Context, ref string, body V1UpdateDatabasePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RunAQueryWithBody request with any body - V1RunAQueryWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1RunAQuery(ctx context.Context, ref string, body V1RunAQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ReadOnlyQueryWithBody request with any body - V1ReadOnlyQueryWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ReadOnlyQuery(ctx context.Context, ref string, body V1ReadOnlyQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1EnableDatabaseWebhook request - V1EnableDatabaseWebhook(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllFunctions request - V1ListAllFunctions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CreateAFunctionWithBody request with any body - V1CreateAFunctionWithBody(ctx context.Context, ref string, params *V1CreateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CreateAFunction(ctx context.Context, ref string, params *V1CreateAFunctionParams, body V1CreateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1BulkUpdateFunctionsWithBody request with any body - V1BulkUpdateFunctionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1BulkUpdateFunctions(ctx context.Context, ref string, body V1BulkUpdateFunctionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeployAFunctionWithBody request with any body - V1DeployAFunctionWithBody(ctx context.Context, ref string, params *V1DeployAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteAFunction request - V1DeleteAFunction(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAFunction request - V1GetAFunction(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateAFunctionWithBody request with any body - V1UpdateAFunctionWithBody(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateAFunction(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, body V1UpdateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetAFunctionBody request - V1GetAFunctionBody(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetServicesHealth request - V1GetServicesHealth(ctx context.Context, ref string, params *V1GetServicesHealthParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetJitAccessConfig request - V1GetJitAccessConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateJitAccessConfigWithBody request with any body - V1UpdateJitAccessConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateJitAccessConfig(ctx context.Context, ref string, body V1UpdateJitAccessConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeleteNetworkBansWithBody request with any body - V1DeleteNetworkBansWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1DeleteNetworkBans(ctx context.Context, ref string, body V1DeleteNetworkBansJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllNetworkBans request - V1ListAllNetworkBans(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllNetworkBansEnriched request - V1ListAllNetworkBansEnriched(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetNetworkRestrictions request - V1GetNetworkRestrictions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1PatchNetworkRestrictionsWithBody request with any body - V1PatchNetworkRestrictionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1PatchNetworkRestrictions(ctx context.Context, ref string, body V1PatchNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateNetworkRestrictionsWithBody request with any body - V1UpdateNetworkRestrictionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateNetworkRestrictions(ctx context.Context, ref string, body V1UpdateNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1PauseAProject request - V1PauseAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPgsodiumConfig request - V1GetPgsodiumConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdatePgsodiumConfigWithBody request with any body - V1UpdatePgsodiumConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdatePgsodiumConfig(ctx context.Context, ref string, body V1UpdatePgsodiumConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPostgrestServiceConfig request - V1GetPostgrestServiceConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdatePostgrestServiceConfigWithBody request with any body - V1UpdatePostgrestServiceConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdatePostgrestServiceConfig(ctx context.Context, ref string, body V1UpdatePostgrestServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RemoveAReadReplicaWithBody request with any body - V1RemoveAReadReplicaWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1RemoveAReadReplica(ctx context.Context, ref string, body V1RemoveAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1SetupAReadReplicaWithBody request with any body - V1SetupAReadReplicaWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1SetupAReadReplica(ctx context.Context, ref string, body V1SetupAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetReadonlyModeStatus request - V1GetReadonlyModeStatus(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DisableReadonlyModeTemporarily request - V1DisableReadonlyModeTemporarily(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RestartAProject request - V1RestartAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAvailableRestoreVersions request - V1ListAvailableRestoreVersions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1RestoreAProject request - V1RestoreAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CancelAProjectRestoration request - V1CancelAProjectRestoration(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1BulkDeleteSecretsWithBody request with any body - V1BulkDeleteSecretsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1BulkDeleteSecrets(ctx context.Context, ref string, body V1BulkDeleteSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllSecrets request - V1ListAllSecrets(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1BulkCreateSecretsWithBody request with any body - V1BulkCreateSecretsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1BulkCreateSecrets(ctx context.Context, ref string, body V1BulkCreateSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetSslEnforcementConfig request - V1GetSslEnforcementConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpdateSslEnforcementConfigWithBody request with any body - V1UpdateSslEnforcementConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpdateSslEnforcementConfig(ctx context.Context, ref string, body V1UpdateSslEnforcementConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllBuckets request - V1ListAllBuckets(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GenerateTypescriptTypes request - V1GenerateTypescriptTypes(ctx context.Context, ref string, params *V1GenerateTypescriptTypesParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1UpgradePostgresVersionWithBody request with any body - V1UpgradePostgresVersionWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1UpgradePostgresVersion(ctx context.Context, ref string, body V1UpgradePostgresVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPostgresUpgradeEligibility request - V1GetPostgresUpgradeEligibility(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetPostgresUpgradeStatus request - V1GetPostgresUpgradeStatus(ctx context.Context, ref string, params *V1GetPostgresUpgradeStatusParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1DeactivateVanitySubdomainConfig request - V1DeactivateVanitySubdomainConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetVanitySubdomainConfig request - V1GetVanitySubdomainConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ActivateVanitySubdomainConfigWithBody request with any body - V1ActivateVanitySubdomainConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1ActivateVanitySubdomainConfig(ctx context.Context, ref string, body V1ActivateVanitySubdomainConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1CheckVanitySubdomainAvailabilityWithBody request with any body - V1CheckVanitySubdomainAvailabilityWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - V1CheckVanitySubdomainAvailability(ctx context.Context, ref string, body V1CheckVanitySubdomainAvailabilityJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1ListAllSnippets request - V1ListAllSnippets(ctx context.Context, params *V1ListAllSnippetsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // V1GetASnippet request - V1GetASnippet(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) -} - -func (c *Client) V1DeleteABranch(ctx context.Context, branchIdOrRef string, params *V1DeleteABranchParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteABranchRequest(c.Server, branchIdOrRef, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetABranchConfig(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetABranchConfigRequest(c.Server, branchIdOrRef) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateABranchConfigWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateABranchConfigRequestWithBody(c.Server, branchIdOrRef, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateABranchConfig(ctx context.Context, branchIdOrRef string, body V1UpdateABranchConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateABranchConfigRequest(c.Server, branchIdOrRef, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DiffABranch(ctx context.Context, branchIdOrRef string, params *V1DiffABranchParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DiffABranchRequest(c.Server, branchIdOrRef, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1MergeABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1MergeABranchRequestWithBody(c.Server, branchIdOrRef, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1MergeABranch(ctx context.Context, branchIdOrRef string, body V1MergeABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1MergeABranchRequest(c.Server, branchIdOrRef, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PushABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PushABranchRequestWithBody(c.Server, branchIdOrRef, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PushABranch(ctx context.Context, branchIdOrRef string, body V1PushABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PushABranchRequest(c.Server, branchIdOrRef, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ResetABranchWithBody(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ResetABranchRequestWithBody(c.Server, branchIdOrRef, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ResetABranch(ctx context.Context, branchIdOrRef string, body V1ResetABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ResetABranchRequest(c.Server, branchIdOrRef, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestoreABranch(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestoreABranchRequest(c.Server, branchIdOrRef) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1AuthorizeUser(ctx context.Context, params *V1AuthorizeUserParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1AuthorizeUserRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1OauthAuthorizeProjectClaim(ctx context.Context, params *V1OauthAuthorizeProjectClaimParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1OauthAuthorizeProjectClaimRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RevokeTokenWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RevokeTokenRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RevokeToken(ctx context.Context, body V1RevokeTokenJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RevokeTokenRequest(c.Server, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ExchangeOauthTokenWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ExchangeOauthTokenRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ExchangeOauthTokenWithFormdataBody(ctx context.Context, body V1ExchangeOauthTokenFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ExchangeOauthTokenRequestWithFormdataBody(c.Server, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllOrganizationsRequest(c.Server) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAnOrganizationWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAnOrganizationRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAnOrganization(ctx context.Context, body V1CreateAnOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAnOrganizationRequest(c.Server, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAnOrganization(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAnOrganizationRequest(c.Server, slug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetOrganizationEntitlements(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetOrganizationEntitlementsRequest(c.Server, slug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListOrganizationMembers(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListOrganizationMembersRequest(c.Server, slug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetOrganizationProjectClaim(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetOrganizationProjectClaimRequest(c.Server, slug, token) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ClaimProjectForOrganization(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ClaimProjectForOrganizationRequest(c.Server, slug, token) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAllProjectsForOrganization(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAllProjectsForOrganizationRequest(c.Server, slug, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProfileRequest(c.Server) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllProjects(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllProjectsRequest(c.Server) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAProjectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAProjectRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAProject(ctx context.Context, body V1CreateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAProjectRequest(c.Server, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAvailableRegions(ctx context.Context, params *V1GetAvailableRegionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAvailableRegionsRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteAProjectRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAProjectWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAProjectRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAProject(ctx context.Context, ref string, body V1UpdateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAProjectRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListActionRuns(ctx context.Context, ref string, params *V1ListActionRunsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListActionRunsRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CountActionRuns(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CountActionRunsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetActionRun(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetActionRunRequest(c.Server, ref, runId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetActionRunLogs(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetActionRunLogsRequest(c.Server, ref, runId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateActionRunStatusWithBody(ctx context.Context, ref string, runId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateActionRunStatusRequestWithBody(c.Server, ref, runId, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateActionRunStatus(ctx context.Context, ref string, runId string, body V1UpdateActionRunStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateActionRunStatusRequest(c.Server, ref, runId, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPerformanceAdvisors(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPerformanceAdvisorsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetSecurityAdvisors(ctx context.Context, ref string, params *V1GetSecurityAdvisorsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetSecurityAdvisorsRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectFunctionCombinedStats(ctx context.Context, ref string, params *V1GetProjectFunctionCombinedStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectFunctionCombinedStatsRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectLogs(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectLogsRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectLogsAll(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectLogsAllRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ScrapeProjectMetrics(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ScrapeProjectMetricsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectUsageApiCount(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectUsageApiCountRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectUsageRequestCount(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectUsageRequestCountRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectApiKeys(ctx context.Context, ref string, params *V1GetProjectApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectApiKeysRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectApiKeyWithBody(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectApiKeyRequestWithBody(c.Server, ref, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectApiKey(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, body V1CreateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectApiKeyRequest(c.Server, ref, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectLegacyApiKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectLegacyApiKeysRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateProjectLegacyApiKeys(ctx context.Context, ref string, params *V1UpdateProjectLegacyApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateProjectLegacyApiKeysRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1DeleteProjectApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteProjectApiKeyRequest(c.Server, ref, id, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1GetProjectApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectApiKeyRequest(c.Server, ref, id, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateProjectApiKeyWithBody(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateProjectApiKeyRequestWithBody(c.Server, ref, id, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateProjectApiKey(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, body V1UpdateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateProjectApiKeyRequest(c.Server, ref, id, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListProjectAddons(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListProjectAddonsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ApplyProjectAddonWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ApplyProjectAddonRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ApplyProjectAddon(ctx context.Context, ref string, body V1ApplyProjectAddonJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ApplyProjectAddonRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RemoveProjectAddon(ctx context.Context, ref string, addonVariant struct { - union json.RawMessage -}, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RemoveProjectAddonRequest(c.Server, ref, addonVariant) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DisablePreviewBranching(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DisablePreviewBranchingRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllBranches(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllBranchesRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateABranchWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateABranchRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateABranch(ctx context.Context, ref string, body V1CreateABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateABranchRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetABranch(ctx context.Context, ref string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetABranchRequest(c.Server, ref, name) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteProjectClaimTokenRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectClaimTokenRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectClaimToken(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectClaimTokenRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteLoginRoles(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteLoginRolesRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateLoginRoleWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateLoginRoleRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateLoginRole(ctx context.Context, ref string, body V1CreateLoginRoleJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateLoginRoleRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAuthServiceConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAuthServiceConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAuthServiceConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAuthServiceConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAuthServiceConfig(ctx context.Context, ref string, body V1UpdateAuthServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAuthServiceConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectSigningKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectSigningKeysRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectSigningKeyWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectSigningKeyRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectSigningKey(ctx context.Context, ref string, body V1CreateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectSigningKeyRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetLegacySigningKey(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetLegacySigningKeyRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateLegacySigningKey(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateLegacySigningKeyRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RemoveProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RemoveProjectSigningKeyRequest(c.Server, ref, id) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectSigningKeyRequest(c.Server, ref, id) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateProjectSigningKeyWithBody(ctx context.Context, ref string, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateProjectSigningKeyRequestWithBody(c.Server, ref, id, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateProjectSigningKey(ctx context.Context, ref string, id openapi_types.UUID, body V1UpdateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateProjectSigningKeyRequest(c.Server, ref, id, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllSsoProvider(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllSsoProviderRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateASsoProviderWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateASsoProviderRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateASsoProvider(ctx context.Context, ref string, body V1CreateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateASsoProviderRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteASsoProviderRequest(c.Server, ref, providerId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetASsoProviderRequest(c.Server, ref, providerId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateASsoProviderWithBody(ctx context.Context, ref string, providerId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateASsoProviderRequestWithBody(c.Server, ref, providerId, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateASsoProvider(ctx context.Context, ref string, providerId openapi_types.UUID, body V1UpdateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateASsoProviderRequest(c.Server, ref, providerId, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListProjectTpaIntegrations(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListProjectTpaIntegrationsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectTpaIntegrationWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectTpaIntegrationRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateProjectTpaIntegration(ctx context.Context, ref string, body V1CreateProjectTpaIntegrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateProjectTpaIntegrationRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteProjectTpaIntegration(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteProjectTpaIntegrationRequest(c.Server, ref, tpaId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectTpaIntegration(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectTpaIntegrationRequest(c.Server, ref, tpaId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectPgbouncerConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectPgbouncerConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPoolerConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPoolerConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePoolerConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePoolerConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePoolerConfig(ctx context.Context, ref string, body V1UpdatePoolerConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePoolerConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPostgresConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPostgresConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePostgresConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePostgresConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePostgresConfig(ctx context.Context, ref string, body V1UpdatePostgresConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePostgresConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetDatabaseDisk(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetDatabaseDiskRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ModifyDatabaseDiskWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ModifyDatabaseDiskRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ModifyDatabaseDisk(ctx context.Context, ref string, body V1ModifyDatabaseDiskJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ModifyDatabaseDiskRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetProjectDiskAutoscaleConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetProjectDiskAutoscaleConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetDiskUtilization(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetDiskUtilizationRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetRealtimeConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetRealtimeConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateRealtimeConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateRealtimeConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateRealtimeConfig(ctx context.Context, ref string, body V1UpdateRealtimeConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateRealtimeConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ShutdownRealtime(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ShutdownRealtimeRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetStorageConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetStorageConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateStorageConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateStorageConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateStorageConfig(ctx context.Context, ref string, body V1UpdateStorageConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateStorageConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteHostnameConfig(ctx context.Context, ref string, params *V1DeleteHostnameConfigParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteHostnameConfigRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetHostnameConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetHostnameConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ActivateCustomHostname(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ActivateCustomHostnameRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateHostnameConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateHostnameConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateHostnameConfig(ctx context.Context, ref string, body V1UpdateHostnameConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateHostnameConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1VerifyDnsConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1VerifyDnsConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllBackups(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllBackupsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestorePhysicalBackupWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestorePhysicalBackupRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestorePhysicalBackup(ctx context.Context, ref string, body V1RestorePhysicalBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestorePhysicalBackupRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestorePitrBackupWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestorePitrBackupRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestorePitrBackup(ctx context.Context, ref string, body V1RestorePitrBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestorePitrBackupRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetRestorePoint(ctx context.Context, ref string, params *V1GetRestorePointParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetRestorePointRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateRestorePointWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateRestorePointRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateRestorePoint(ctx context.Context, ref string, body V1CreateRestorePointJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateRestorePointRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetBackupSchedule(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetBackupScheduleRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateBackupScheduleWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateBackupScheduleRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateBackupSchedule(ctx context.Context, ref string, body V1UpdateBackupScheduleJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateBackupScheduleRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UndoWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UndoRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1Undo(ctx context.Context, ref string, body V1UndoJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UndoRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetDatabaseMetadata(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetDatabaseMetadataRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetJitAccess(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetJitAccessRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1AuthorizeJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1AuthorizeJitAccessRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1AuthorizeJitAccess(ctx context.Context, ref string, body V1AuthorizeJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1AuthorizeJitAccessRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateJitAccessRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateJitAccess(ctx context.Context, ref string, body V1UpdateJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateJitAccessRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1InviteExternalJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1InviteExternalJitAccessRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1InviteExternalJitAccess(ctx context.Context, ref string, body V1InviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1InviteExternalJitAccessRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1AcceptInviteExternalJitAccessWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1AcceptInviteExternalJitAccessRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1AcceptInviteExternalJitAccess(ctx context.Context, ref string, body V1AcceptInviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1AcceptInviteExternalJitAccessRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteInviteExternalJitAccess(ctx context.Context, ref string, inviteId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteInviteExternalJitAccessRequest(c.Server, ref, inviteId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListJitAccess(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListJitAccessRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteJitAccess(ctx context.Context, ref string, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteJitAccessRequest(c.Server, ref, userId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RollbackMigrations(ctx context.Context, ref string, params *V1RollbackMigrationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RollbackMigrationsRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListMigrationHistory(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListMigrationHistoryRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ApplyAMigrationWithBody(ctx context.Context, ref string, params *V1ApplyAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ApplyAMigrationRequestWithBody(c.Server, ref, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ApplyAMigration(ctx context.Context, ref string, params *V1ApplyAMigrationParams, body V1ApplyAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ApplyAMigrationRequest(c.Server, ref, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpsertAMigrationWithBody(ctx context.Context, ref string, params *V1UpsertAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpsertAMigrationRequestWithBody(c.Server, ref, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpsertAMigration(ctx context.Context, ref string, params *V1UpsertAMigrationParams, body V1UpsertAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpsertAMigrationRequest(c.Server, ref, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAMigration(ctx context.Context, ref string, version string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAMigrationRequest(c.Server, ref, version) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PatchAMigrationWithBody(ctx context.Context, ref string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PatchAMigrationRequestWithBody(c.Server, ref, version, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PatchAMigration(ctx context.Context, ref string, version string, body V1PatchAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PatchAMigrationRequest(c.Server, ref, version, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetDatabaseOpenapi(ctx context.Context, ref string, params *V1GetDatabaseOpenapiParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetDatabaseOpenapiRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateDatabasePasswordWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateDatabasePasswordRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateDatabasePassword(ctx context.Context, ref string, body V1UpdateDatabasePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateDatabasePasswordRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RunAQueryWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RunAQueryRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RunAQuery(ctx context.Context, ref string, body V1RunAQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RunAQueryRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ReadOnlyQueryWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ReadOnlyQueryRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ReadOnlyQuery(ctx context.Context, ref string, body V1ReadOnlyQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ReadOnlyQueryRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1EnableDatabaseWebhook(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1EnableDatabaseWebhookRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllFunctions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllFunctionsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAFunctionWithBody(ctx context.Context, ref string, params *V1CreateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAFunctionRequestWithBody(c.Server, ref, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CreateAFunction(ctx context.Context, ref string, params *V1CreateAFunctionParams, body V1CreateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CreateAFunctionRequest(c.Server, ref, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkUpdateFunctionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkUpdateFunctionsRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkUpdateFunctions(ctx context.Context, ref string, body V1BulkUpdateFunctionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkUpdateFunctionsRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeployAFunctionWithBody(ctx context.Context, ref string, params *V1DeployAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeployAFunctionRequestWithBody(c.Server, ref, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteAFunction(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteAFunctionRequest(c.Server, ref, functionSlug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAFunction(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAFunctionRequest(c.Server, ref, functionSlug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAFunctionWithBody(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAFunctionRequestWithBody(c.Server, ref, functionSlug, params, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateAFunction(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, body V1UpdateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateAFunctionRequest(c.Server, ref, functionSlug, params, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetAFunctionBody(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetAFunctionBodyRequest(c.Server, ref, functionSlug) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetServicesHealth(ctx context.Context, ref string, params *V1GetServicesHealthParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetServicesHealthRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetJitAccessConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetJitAccessConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateJitAccessConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateJitAccessConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateJitAccessConfig(ctx context.Context, ref string, body V1UpdateJitAccessConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateJitAccessConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteNetworkBansWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteNetworkBansRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeleteNetworkBans(ctx context.Context, ref string, body V1DeleteNetworkBansJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeleteNetworkBansRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllNetworkBans(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllNetworkBansRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllNetworkBansEnriched(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllNetworkBansEnrichedRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetNetworkRestrictions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetNetworkRestrictionsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PatchNetworkRestrictionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PatchNetworkRestrictionsRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PatchNetworkRestrictions(ctx context.Context, ref string, body V1PatchNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PatchNetworkRestrictionsRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateNetworkRestrictionsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateNetworkRestrictionsRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateNetworkRestrictions(ctx context.Context, ref string, body V1UpdateNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateNetworkRestrictionsRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1PauseAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1PauseAProjectRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPgsodiumConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPgsodiumConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePgsodiumConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePgsodiumConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePgsodiumConfig(ctx context.Context, ref string, body V1UpdatePgsodiumConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePgsodiumConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPostgrestServiceConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPostgrestServiceConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePostgrestServiceConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePostgrestServiceConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdatePostgrestServiceConfig(ctx context.Context, ref string, body V1UpdatePostgrestServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdatePostgrestServiceConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RemoveAReadReplicaWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RemoveAReadReplicaRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RemoveAReadReplica(ctx context.Context, ref string, body V1RemoveAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RemoveAReadReplicaRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1SetupAReadReplicaWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1SetupAReadReplicaRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1SetupAReadReplica(ctx context.Context, ref string, body V1SetupAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1SetupAReadReplicaRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetReadonlyModeStatus(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetReadonlyModeStatusRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DisableReadonlyModeTemporarily(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DisableReadonlyModeTemporarilyRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestartAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestartAProjectRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAvailableRestoreVersions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAvailableRestoreVersionsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1RestoreAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1RestoreAProjectRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CancelAProjectRestoration(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CancelAProjectRestorationRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkDeleteSecretsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkDeleteSecretsRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkDeleteSecrets(ctx context.Context, ref string, body V1BulkDeleteSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkDeleteSecretsRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllSecrets(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllSecretsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkCreateSecretsWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkCreateSecretsRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1BulkCreateSecrets(ctx context.Context, ref string, body V1BulkCreateSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1BulkCreateSecretsRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetSslEnforcementConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetSslEnforcementConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateSslEnforcementConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateSslEnforcementConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpdateSslEnforcementConfig(ctx context.Context, ref string, body V1UpdateSslEnforcementConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpdateSslEnforcementConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllBuckets(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllBucketsRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GenerateTypescriptTypes(ctx context.Context, ref string, params *V1GenerateTypescriptTypesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GenerateTypescriptTypesRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpgradePostgresVersionWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpgradePostgresVersionRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1UpgradePostgresVersion(ctx context.Context, ref string, body V1UpgradePostgresVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1UpgradePostgresVersionRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPostgresUpgradeEligibility(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPostgresUpgradeEligibilityRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetPostgresUpgradeStatus(ctx context.Context, ref string, params *V1GetPostgresUpgradeStatusParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetPostgresUpgradeStatusRequest(c.Server, ref, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1DeactivateVanitySubdomainConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1DeactivateVanitySubdomainConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetVanitySubdomainConfig(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetVanitySubdomainConfigRequest(c.Server, ref) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ActivateVanitySubdomainConfigWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ActivateVanitySubdomainConfigRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ActivateVanitySubdomainConfig(ctx context.Context, ref string, body V1ActivateVanitySubdomainConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ActivateVanitySubdomainConfigRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CheckVanitySubdomainAvailabilityWithBody(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CheckVanitySubdomainAvailabilityRequestWithBody(c.Server, ref, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1CheckVanitySubdomainAvailability(ctx context.Context, ref string, body V1CheckVanitySubdomainAvailabilityJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1CheckVanitySubdomainAvailabilityRequest(c.Server, ref, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1ListAllSnippets(ctx context.Context, params *V1ListAllSnippetsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1ListAllSnippetsRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) V1GetASnippet(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewV1GetASnippetRequest(c.Server, id) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -// NewV1DeleteABranchRequest generates requests for V1DeleteABranch -func NewV1DeleteABranchRequest(server string, branchIdOrRef string, params *V1DeleteABranchParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Force != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "force", *params.Force, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetABranchConfigRequest generates requests for V1GetABranchConfig -func NewV1GetABranchConfigRequest(server string, branchIdOrRef string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateABranchConfigRequest calls the generic V1UpdateABranchConfig builder with application/json body -func NewV1UpdateABranchConfigRequest(server string, branchIdOrRef string, body V1UpdateABranchConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateABranchConfigRequestWithBody(server, branchIdOrRef, "application/json", bodyReader) -} - -// NewV1UpdateABranchConfigRequestWithBody generates requests for V1UpdateABranchConfig with any type of body -func NewV1UpdateABranchConfigRequestWithBody(server string, branchIdOrRef string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DiffABranchRequest generates requests for V1DiffABranch -func NewV1DiffABranchRequest(server string, branchIdOrRef string, params *V1DiffABranchParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s/diff", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.IncludedSchemas != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "included_schemas", *params.IncludedSchemas, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Pgdelta != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pgdelta", *params.Pgdelta, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1MergeABranchRequest calls the generic V1MergeABranch builder with application/json body -func NewV1MergeABranchRequest(server string, branchIdOrRef string, body V1MergeABranchJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1MergeABranchRequestWithBody(server, branchIdOrRef, "application/json", bodyReader) -} - -// NewV1MergeABranchRequestWithBody generates requests for V1MergeABranch with any type of body -func NewV1MergeABranchRequestWithBody(server string, branchIdOrRef string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s/merge", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1PushABranchRequest calls the generic V1PushABranch builder with application/json body -func NewV1PushABranchRequest(server string, branchIdOrRef string, body V1PushABranchJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1PushABranchRequestWithBody(server, branchIdOrRef, "application/json", bodyReader) -} - -// NewV1PushABranchRequestWithBody generates requests for V1PushABranch with any type of body -func NewV1PushABranchRequestWithBody(server string, branchIdOrRef string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s/push", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ResetABranchRequest calls the generic V1ResetABranch builder with application/json body -func NewV1ResetABranchRequest(server string, branchIdOrRef string, body V1ResetABranchJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ResetABranchRequestWithBody(server, branchIdOrRef, "application/json", bodyReader) -} - -// NewV1ResetABranchRequestWithBody generates requests for V1ResetABranch with any type of body -func NewV1ResetABranchRequestWithBody(server string, branchIdOrRef string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s/reset", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1RestoreABranchRequest generates requests for V1RestoreABranch -func NewV1RestoreABranchRequest(server string, branchIdOrRef string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "branch_id_or_ref", branchIdOrRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/branches/%s/restore", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1AuthorizeUserRequest generates requests for V1AuthorizeUser -func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/oauth/authorize") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "client_id", params.ClientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uuid"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "response_type", params.ResponseType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "redirect_uri", params.RedirectUri, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if params.Scope != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "scope", *params.Scope, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.State != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "state", *params.State, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ResponseMode != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "response_mode", *params.ResponseMode, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.CodeChallenge != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "code_challenge", *params.CodeChallenge, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.CodeChallengeMethod != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "code_challenge_method", *params.CodeChallengeMethod, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.OrganizationSlug != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "organization_slug", *params.OrganizationSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.TargetFlow != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "target_flow", *params.TargetFlow, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Resource != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "resource", *params.Resource, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uri"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1OauthAuthorizeProjectClaimRequest generates requests for V1OauthAuthorizeProjectClaim -func NewV1OauthAuthorizeProjectClaimRequest(server string, params *V1OauthAuthorizeProjectClaimParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/oauth/authorize/project-claim") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "project_ref", params.ProjectRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "client_id", params.ClientId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uuid"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "response_type", params.ResponseType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "redirect_uri", params.RedirectUri, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if params.State != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "state", *params.State, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ResponseMode != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "response_mode", *params.ResponseMode, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.CodeChallenge != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "code_challenge", *params.CodeChallenge, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.CodeChallengeMethod != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "code_challenge_method", *params.CodeChallengeMethod, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RevokeTokenRequest calls the generic V1RevokeToken builder with application/json body -func NewV1RevokeTokenRequest(server string, body V1RevokeTokenJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1RevokeTokenRequestWithBody(server, "application/json", bodyReader) -} - -// NewV1RevokeTokenRequestWithBody generates requests for V1RevokeToken with any type of body -func NewV1RevokeTokenRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/oauth/revoke") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ExchangeOauthTokenRequestWithFormdataBody calls the generic V1ExchangeOauthToken builder with application/x-www-form-urlencoded body -func NewV1ExchangeOauthTokenRequestWithFormdataBody(server string, body V1ExchangeOauthTokenFormdataRequestBody) (*http.Request, error) { - var bodyReader io.Reader - bodyStr, err := runtime.MarshalForm(body, nil) - if err != nil { - return nil, err - } - bodyReader = strings.NewReader(bodyStr.Encode()) - return NewV1ExchangeOauthTokenRequestWithBody(server, "application/x-www-form-urlencoded", bodyReader) -} - -// NewV1ExchangeOauthTokenRequestWithBody generates requests for V1ExchangeOauthToken with any type of body -func NewV1ExchangeOauthTokenRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/oauth/token") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllOrganizationsRequest generates requests for V1ListAllOrganizations -func NewV1ListAllOrganizationsRequest(server string) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateAnOrganizationRequest calls the generic V1CreateAnOrganization builder with application/json body -func NewV1CreateAnOrganizationRequest(server string, body V1CreateAnOrganizationJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateAnOrganizationRequestWithBody(server, "application/json", bodyReader) -} - -// NewV1CreateAnOrganizationRequestWithBody generates requests for V1CreateAnOrganization with any type of body -func NewV1CreateAnOrganizationRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetAnOrganizationRequest generates requests for V1GetAnOrganization -func NewV1GetAnOrganizationRequest(server string, slug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetOrganizationEntitlementsRequest generates requests for V1GetOrganizationEntitlements -func NewV1GetOrganizationEntitlementsRequest(server string, slug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s/entitlements", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListOrganizationMembersRequest generates requests for V1ListOrganizationMembers -func NewV1ListOrganizationMembersRequest(server string, slug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s/members", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetOrganizationProjectClaimRequest generates requests for V1GetOrganizationProjectClaim -func NewV1GetOrganizationProjectClaimRequest(server string, slug string, token string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s/project-claim/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ClaimProjectForOrganizationRequest generates requests for V1ClaimProjectForOrganization -func NewV1ClaimProjectForOrganizationRequest(server string, slug string, token string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s/project-claim/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetAllProjectsForOrganizationRequest generates requests for V1GetAllProjectsForOrganization -func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, params *V1GetAllProjectsForOrganizationParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "slug", slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/organizations/%s/projects", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Offset != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "offset", *params.Offset, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Limit != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Search != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "search", *params.Search, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Sort != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "sort", *params.Sort, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Statuses != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "statuses", *params.Statuses, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProfileRequest generates requests for V1GetProfile -func NewV1GetProfileRequest(server string) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/profile") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAllProjectsRequest generates requests for V1ListAllProjects -func NewV1ListAllProjectsRequest(server string) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateAProjectRequest calls the generic V1CreateAProject builder with application/json body -func NewV1CreateAProjectRequest(server string, body V1CreateAProjectJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateAProjectRequestWithBody(server, "application/json", bodyReader) -} - -// NewV1CreateAProjectRequestWithBody generates requests for V1CreateAProject with any type of body -func NewV1CreateAProjectRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetAvailableRegionsRequest generates requests for V1GetAvailableRegions -func NewV1GetAvailableRegionsRequest(server string, params *V1GetAvailableRegionsParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/available-regions") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "organization_slug", params.OrganizationSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if params.Continent != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "continent", *params.Continent, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.DesiredInstanceSize != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "desired_instance_size", *params.DesiredInstanceSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeleteAProjectRequest generates requests for V1DeleteAProject -func NewV1DeleteAProjectRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectRequest generates requests for V1GetProject -func NewV1GetProjectRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateAProjectRequest calls the generic V1UpdateAProject builder with application/json body -func NewV1UpdateAProjectRequest(server string, ref string, body V1UpdateAProjectJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateAProjectRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateAProjectRequestWithBody generates requests for V1UpdateAProject with any type of body -func NewV1UpdateAProjectRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListActionRunsRequest generates requests for V1ListActionRuns -func NewV1ListActionRunsRequest(server string, ref string, params *V1ListActionRunsParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/actions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Offset != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "offset", *params.Offset, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "number", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Limit != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "number", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CountActionRunsRequest generates requests for V1CountActionRuns -func NewV1CountActionRunsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/actions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodHead, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetActionRunRequest generates requests for V1GetActionRun -func NewV1GetActionRunRequest(server string, ref string, runId string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/actions/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetActionRunLogsRequest generates requests for V1GetActionRunLogs -func NewV1GetActionRunLogsRequest(server string, ref string, runId string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/actions/%s/logs", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateActionRunStatusRequest calls the generic V1UpdateActionRunStatus builder with application/json body -func NewV1UpdateActionRunStatusRequest(server string, ref string, runId string, body V1UpdateActionRunStatusJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateActionRunStatusRequestWithBody(server, ref, runId, "application/json", bodyReader) -} - -// NewV1UpdateActionRunStatusRequestWithBody generates requests for V1UpdateActionRunStatus with any type of body -func NewV1UpdateActionRunStatusRequestWithBody(server string, ref string, runId string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/actions/%s/status", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetPerformanceAdvisorsRequest generates requests for V1GetPerformanceAdvisors -func NewV1GetPerformanceAdvisorsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/advisors/performance", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetSecurityAdvisorsRequest generates requests for V1GetSecurityAdvisors -func NewV1GetSecurityAdvisorsRequest(server string, ref string, params *V1GetSecurityAdvisorsParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/advisors/security", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.LintType != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "lint_type", *params.LintType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectFunctionCombinedStatsRequest generates requests for V1GetProjectFunctionCombinedStats -func NewV1GetProjectFunctionCombinedStatsRequest(server string, ref string, params *V1GetProjectFunctionCombinedStatsParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/functions.combined-stats", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "interval", params.Interval, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function_id", params.FunctionId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectLogsRequest generates requests for V1GetProjectLogs -func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectLogsParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/logs", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Sql != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "sql", *params.Sql, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.IsoTimestampStart != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "iso_timestamp_start", *params.IsoTimestampStart, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.IsoTimestampEnd != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "iso_timestamp_end", *params.IsoTimestampEnd, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectLogsAllRequest generates requests for V1GetProjectLogsAll -func NewV1GetProjectLogsAllRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/logs.all", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ScrapeProjectMetricsRequest generates requests for V1ScrapeProjectMetrics -func NewV1ScrapeProjectMetricsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/metrics", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectUsageApiCountRequest generates requests for V1GetProjectUsageApiCount -func NewV1GetProjectUsageApiCountRequest(server string, ref string, params *V1GetProjectUsageApiCountParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/usage.api-counts", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Interval != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "interval", *params.Interval, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectUsageRequestCountRequest generates requests for V1GetProjectUsageRequestCount -func NewV1GetProjectUsageRequestCountRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/usage.api-requests-count", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectApiKeysRequest generates requests for V1GetProjectApiKeys -func NewV1GetProjectApiKeysRequest(server string, ref string, params *V1GetProjectApiKeysParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Reveal != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateProjectApiKeyRequest calls the generic V1CreateProjectApiKey builder with application/json body -func NewV1CreateProjectApiKeyRequest(server string, ref string, params *V1CreateProjectApiKeyParams, body V1CreateProjectApiKeyJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateProjectApiKeyRequestWithBody(server, ref, params, "application/json", bodyReader) -} - -// NewV1CreateProjectApiKeyRequestWithBody generates requests for V1CreateProjectApiKey with any type of body -func NewV1CreateProjectApiKeyRequestWithBody(server string, ref string, params *V1CreateProjectApiKeyParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Reveal != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetProjectLegacyApiKeysRequest generates requests for V1GetProjectLegacyApiKeys -func NewV1GetProjectLegacyApiKeysRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys/legacy", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateProjectLegacyApiKeysRequest generates requests for V1UpdateProjectLegacyApiKeys -func NewV1UpdateProjectLegacyApiKeysRequest(server string, ref string, params *V1UpdateProjectLegacyApiKeysParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys/legacy", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "enabled", params.Enabled, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeleteProjectApiKeyRequest generates requests for V1DeleteProjectApiKey -func NewV1DeleteProjectApiKeyRequest(server string, ref string, id openapi_types.UUID, params *V1DeleteProjectApiKeyParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Reveal != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.WasCompromised != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "was_compromised", *params.WasCompromised, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Reason != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reason", *params.Reason, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectApiKeyRequest generates requests for V1GetProjectApiKey -func NewV1GetProjectApiKeyRequest(server string, ref string, id openapi_types.UUID, params *V1GetProjectApiKeyParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Reveal != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateProjectApiKeyRequest calls the generic V1UpdateProjectApiKey builder with application/json body -func NewV1UpdateProjectApiKeyRequest(server string, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, body V1UpdateProjectApiKeyJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateProjectApiKeyRequestWithBody(server, ref, id, params, "application/json", bodyReader) -} - -// NewV1UpdateProjectApiKeyRequestWithBody generates requests for V1UpdateProjectApiKey with any type of body -func NewV1UpdateProjectApiKeyRequestWithBody(server string, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/api-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Reveal != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "reveal", *params.Reveal, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListProjectAddonsRequest generates requests for V1ListProjectAddons -func NewV1ListProjectAddonsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/billing/addons", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ApplyProjectAddonRequest calls the generic V1ApplyProjectAddon builder with application/json body -func NewV1ApplyProjectAddonRequest(server string, ref string, body V1ApplyProjectAddonJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ApplyProjectAddonRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1ApplyProjectAddonRequestWithBody generates requests for V1ApplyProjectAddon with any type of body -func NewV1ApplyProjectAddonRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/billing/addons", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1RemoveProjectAddonRequest generates requests for V1RemoveProjectAddon -func NewV1RemoveProjectAddonRequest(server string, ref string, addonVariant struct { - union json.RawMessage -}) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "addon_variant", addonVariant, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/billing/addons/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DisablePreviewBranchingRequest generates requests for V1DisablePreviewBranching -func NewV1DisablePreviewBranchingRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/branches", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAllBranchesRequest generates requests for V1ListAllBranches -func NewV1ListAllBranchesRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/branches", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateABranchRequest calls the generic V1CreateABranch builder with application/json body -func NewV1CreateABranchRequest(server string, ref string, body V1CreateABranchJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateABranchRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateABranchRequestWithBody generates requests for V1CreateABranch with any type of body -func NewV1CreateABranchRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/branches", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetABranchRequest generates requests for V1GetABranch -func NewV1GetABranchRequest(server string, ref string, name string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/branches/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeleteProjectClaimTokenRequest generates requests for V1DeleteProjectClaimToken -func NewV1DeleteProjectClaimTokenRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/claim-token", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectClaimTokenRequest generates requests for V1GetProjectClaimToken -func NewV1GetProjectClaimTokenRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/claim-token", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateProjectClaimTokenRequest generates requests for V1CreateProjectClaimToken -func NewV1CreateProjectClaimTokenRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/claim-token", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeleteLoginRolesRequest generates requests for V1DeleteLoginRoles -func NewV1DeleteLoginRolesRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/cli/login-role", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateLoginRoleRequest calls the generic V1CreateLoginRole builder with application/json body -func NewV1CreateLoginRoleRequest(server string, ref string, body V1CreateLoginRoleJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateLoginRoleRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateLoginRoleRequestWithBody generates requests for V1CreateLoginRole with any type of body -func NewV1CreateLoginRoleRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/cli/login-role", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetAuthServiceConfigRequest generates requests for V1GetAuthServiceConfig -func NewV1GetAuthServiceConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateAuthServiceConfigRequest calls the generic V1UpdateAuthServiceConfig builder with application/json body -func NewV1UpdateAuthServiceConfigRequest(server string, ref string, body V1UpdateAuthServiceConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateAuthServiceConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateAuthServiceConfigRequestWithBody generates requests for V1UpdateAuthServiceConfig with any type of body -func NewV1UpdateAuthServiceConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetProjectSigningKeysRequest generates requests for V1GetProjectSigningKeys -func NewV1GetProjectSigningKeysRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateProjectSigningKeyRequest calls the generic V1CreateProjectSigningKey builder with application/json body -func NewV1CreateProjectSigningKeyRequest(server string, ref string, body V1CreateProjectSigningKeyJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateProjectSigningKeyRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateProjectSigningKeyRequestWithBody generates requests for V1CreateProjectSigningKey with any type of body -func NewV1CreateProjectSigningKeyRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetLegacySigningKeyRequest generates requests for V1GetLegacySigningKey -func NewV1GetLegacySigningKeyRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys/legacy", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateLegacySigningKeyRequest generates requests for V1CreateLegacySigningKey -func NewV1CreateLegacySigningKeyRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys/legacy", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RemoveProjectSigningKeyRequest generates requests for V1RemoveProjectSigningKey -func NewV1RemoveProjectSigningKeyRequest(server string, ref string, id openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectSigningKeyRequest generates requests for V1GetProjectSigningKey -func NewV1GetProjectSigningKeyRequest(server string, ref string, id openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateProjectSigningKeyRequest calls the generic V1UpdateProjectSigningKey builder with application/json body -func NewV1UpdateProjectSigningKeyRequest(server string, ref string, id openapi_types.UUID, body V1UpdateProjectSigningKeyJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateProjectSigningKeyRequestWithBody(server, ref, id, "application/json", bodyReader) -} - -// NewV1UpdateProjectSigningKeyRequestWithBody generates requests for V1UpdateProjectSigningKey with any type of body -func NewV1UpdateProjectSigningKeyRequestWithBody(server string, ref string, id openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/signing-keys/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllSsoProviderRequest generates requests for V1ListAllSsoProvider -func NewV1ListAllSsoProviderRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/sso/providers", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateASsoProviderRequest calls the generic V1CreateASsoProvider builder with application/json body -func NewV1CreateASsoProviderRequest(server string, ref string, body V1CreateASsoProviderJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateASsoProviderRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateASsoProviderRequestWithBody generates requests for V1CreateASsoProvider with any type of body -func NewV1CreateASsoProviderRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/sso/providers", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteASsoProviderRequest generates requests for V1DeleteASsoProvider -func NewV1DeleteASsoProviderRequest(server string, ref string, providerId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/sso/providers/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetASsoProviderRequest generates requests for V1GetASsoProvider -func NewV1GetASsoProviderRequest(server string, ref string, providerId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/sso/providers/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateASsoProviderRequest calls the generic V1UpdateASsoProvider builder with application/json body -func NewV1UpdateASsoProviderRequest(server string, ref string, providerId openapi_types.UUID, body V1UpdateASsoProviderJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateASsoProviderRequestWithBody(server, ref, providerId, "application/json", bodyReader) -} - -// NewV1UpdateASsoProviderRequestWithBody generates requests for V1UpdateASsoProvider with any type of body -func NewV1UpdateASsoProviderRequestWithBody(server string, ref string, providerId openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider_id", providerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/sso/providers/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListProjectTpaIntegrationsRequest generates requests for V1ListProjectTpaIntegrations -func NewV1ListProjectTpaIntegrationsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/third-party-auth", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateProjectTpaIntegrationRequest calls the generic V1CreateProjectTpaIntegration builder with application/json body -func NewV1CreateProjectTpaIntegrationRequest(server string, ref string, body V1CreateProjectTpaIntegrationJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateProjectTpaIntegrationRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateProjectTpaIntegrationRequestWithBody generates requests for V1CreateProjectTpaIntegration with any type of body -func NewV1CreateProjectTpaIntegrationRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/third-party-auth", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteProjectTpaIntegrationRequest generates requests for V1DeleteProjectTpaIntegration -func NewV1DeleteProjectTpaIntegrationRequest(server string, ref string, tpaId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "tpa_id", tpaId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/third-party-auth/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectTpaIntegrationRequest generates requests for V1GetProjectTpaIntegration -func NewV1GetProjectTpaIntegrationRequest(server string, ref string, tpaId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "tpa_id", tpaId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/auth/third-party-auth/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetProjectPgbouncerConfigRequest generates requests for V1GetProjectPgbouncerConfig -func NewV1GetProjectPgbouncerConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/database/pgbouncer", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetPoolerConfigRequest generates requests for V1GetPoolerConfig -func NewV1GetPoolerConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/database/pooler", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdatePoolerConfigRequest calls the generic V1UpdatePoolerConfig builder with application/json body -func NewV1UpdatePoolerConfigRequest(server string, ref string, body V1UpdatePoolerConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdatePoolerConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdatePoolerConfigRequestWithBody generates requests for V1UpdatePoolerConfig with any type of body -func NewV1UpdatePoolerConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/database/pooler", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetPostgresConfigRequest generates requests for V1GetPostgresConfig -func NewV1GetPostgresConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/database/postgres", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdatePostgresConfigRequest calls the generic V1UpdatePostgresConfig builder with application/json body -func NewV1UpdatePostgresConfigRequest(server string, ref string, body V1UpdatePostgresConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdatePostgresConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdatePostgresConfigRequestWithBody generates requests for V1UpdatePostgresConfig with any type of body -func NewV1UpdatePostgresConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/database/postgres", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetDatabaseDiskRequest generates requests for V1GetDatabaseDisk -func NewV1GetDatabaseDiskRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/disk", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ModifyDatabaseDiskRequest calls the generic V1ModifyDatabaseDisk builder with application/json body -func NewV1ModifyDatabaseDiskRequest(server string, ref string, body V1ModifyDatabaseDiskJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ModifyDatabaseDiskRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1ModifyDatabaseDiskRequestWithBody generates requests for V1ModifyDatabaseDisk with any type of body -func NewV1ModifyDatabaseDiskRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/disk", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetProjectDiskAutoscaleConfigRequest generates requests for V1GetProjectDiskAutoscaleConfig -func NewV1GetProjectDiskAutoscaleConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/disk/autoscale", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetDiskUtilizationRequest generates requests for V1GetDiskUtilization -func NewV1GetDiskUtilizationRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/disk/util", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetRealtimeConfigRequest generates requests for V1GetRealtimeConfig -func NewV1GetRealtimeConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/realtime", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateRealtimeConfigRequest calls the generic V1UpdateRealtimeConfig builder with application/json body -func NewV1UpdateRealtimeConfigRequest(server string, ref string, body V1UpdateRealtimeConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateRealtimeConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateRealtimeConfigRequestWithBody generates requests for V1UpdateRealtimeConfig with any type of body -func NewV1UpdateRealtimeConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/realtime", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ShutdownRealtimeRequest generates requests for V1ShutdownRealtime -func NewV1ShutdownRealtimeRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/realtime/shutdown", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetStorageConfigRequest generates requests for V1GetStorageConfig -func NewV1GetStorageConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/storage", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateStorageConfigRequest calls the generic V1UpdateStorageConfig builder with application/json body -func NewV1UpdateStorageConfigRequest(server string, ref string, body V1UpdateStorageConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateStorageConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateStorageConfigRequestWithBody generates requests for V1UpdateStorageConfig with any type of body -func NewV1UpdateStorageConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/config/storage", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteHostnameConfigRequest generates requests for V1DeleteHostnameConfig -func NewV1DeleteHostnameConfigRequest(server string, ref string, params *V1DeleteHostnameConfigParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/custom-hostname", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.RemoveAddon != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "remove_addon", *params.RemoveAddon, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetHostnameConfigRequest generates requests for V1GetHostnameConfig -func NewV1GetHostnameConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/custom-hostname", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ActivateCustomHostnameRequest generates requests for V1ActivateCustomHostname -func NewV1ActivateCustomHostnameRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/custom-hostname/activate", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateHostnameConfigRequest calls the generic V1UpdateHostnameConfig builder with application/json body -func NewV1UpdateHostnameConfigRequest(server string, ref string, body V1UpdateHostnameConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateHostnameConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateHostnameConfigRequestWithBody generates requests for V1UpdateHostnameConfig with any type of body -func NewV1UpdateHostnameConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/custom-hostname/initialize", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1VerifyDnsConfigRequest generates requests for V1VerifyDnsConfig -func NewV1VerifyDnsConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/custom-hostname/reverify", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAllBackupsRequest generates requests for V1ListAllBackups -func NewV1ListAllBackupsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RestorePhysicalBackupRequest calls the generic V1RestorePhysicalBackup builder with application/json body -func NewV1RestorePhysicalBackupRequest(server string, ref string, body V1RestorePhysicalBackupJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1RestorePhysicalBackupRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1RestorePhysicalBackupRequestWithBody generates requests for V1RestorePhysicalBackup with any type of body -func NewV1RestorePhysicalBackupRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/restore", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1RestorePitrBackupRequest calls the generic V1RestorePitrBackup builder with application/json body -func NewV1RestorePitrBackupRequest(server string, ref string, body V1RestorePitrBackupJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1RestorePitrBackupRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1RestorePitrBackupRequestWithBody generates requests for V1RestorePitrBackup with any type of body -func NewV1RestorePitrBackupRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/restore-pitr", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetRestorePointRequest generates requests for V1GetRestorePoint -func NewV1GetRestorePointRequest(server string, ref string, params *V1GetRestorePointParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/restore-point", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Name != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateRestorePointRequest calls the generic V1CreateRestorePoint builder with application/json body -func NewV1CreateRestorePointRequest(server string, ref string, body V1CreateRestorePointJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateRestorePointRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CreateRestorePointRequestWithBody generates requests for V1CreateRestorePoint with any type of body -func NewV1CreateRestorePointRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/restore-point", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetBackupScheduleRequest generates requests for V1GetBackupSchedule -func NewV1GetBackupScheduleRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/schedule", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateBackupScheduleRequest calls the generic V1UpdateBackupSchedule builder with application/json body -func NewV1UpdateBackupScheduleRequest(server string, ref string, body V1UpdateBackupScheduleJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateBackupScheduleRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateBackupScheduleRequestWithBody generates requests for V1UpdateBackupSchedule with any type of body -func NewV1UpdateBackupScheduleRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/schedule", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1UndoRequest calls the generic V1Undo builder with application/json body -func NewV1UndoRequest(server string, ref string, body V1UndoJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UndoRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UndoRequestWithBody generates requests for V1Undo with any type of body -func NewV1UndoRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/backups/undo", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetDatabaseMetadataRequest generates requests for V1GetDatabaseMetadata -func NewV1GetDatabaseMetadataRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/context", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetJitAccessRequest generates requests for V1GetJitAccess -func NewV1GetJitAccessRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1AuthorizeJitAccessRequest calls the generic V1AuthorizeJitAccess builder with application/json body -func NewV1AuthorizeJitAccessRequest(server string, ref string, body V1AuthorizeJitAccessJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1AuthorizeJitAccessRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1AuthorizeJitAccessRequestWithBody generates requests for V1AuthorizeJitAccess with any type of body -func NewV1AuthorizeJitAccessRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1UpdateJitAccessRequest calls the generic V1UpdateJitAccess builder with application/json body -func NewV1UpdateJitAccessRequest(server string, ref string, body V1UpdateJitAccessJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateJitAccessRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateJitAccessRequestWithBody generates requests for V1UpdateJitAccess with any type of body -func NewV1UpdateJitAccessRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1InviteExternalJitAccessRequest calls the generic V1InviteExternalJitAccess builder with application/json body -func NewV1InviteExternalJitAccessRequest(server string, ref string, body V1InviteExternalJitAccessJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1InviteExternalJitAccessRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1InviteExternalJitAccessRequestWithBody generates requests for V1InviteExternalJitAccess with any type of body -func NewV1InviteExternalJitAccessRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit/invite", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1AcceptInviteExternalJitAccessRequest calls the generic V1AcceptInviteExternalJitAccess builder with application/json body -func NewV1AcceptInviteExternalJitAccessRequest(server string, ref string, body V1AcceptInviteExternalJitAccessJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1AcceptInviteExternalJitAccessRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1AcceptInviteExternalJitAccessRequestWithBody generates requests for V1AcceptInviteExternalJitAccess with any type of body -func NewV1AcceptInviteExternalJitAccessRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit/invite/accept", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteInviteExternalJitAccessRequest generates requests for V1DeleteInviteExternalJitAccess -func NewV1DeleteInviteExternalJitAccessRequest(server string, ref string, inviteId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "invite_id", inviteId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit/invite/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListJitAccessRequest generates requests for V1ListJitAccess -func NewV1ListJitAccessRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit/list", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeleteJitAccessRequest generates requests for V1DeleteJitAccess -func NewV1DeleteJitAccessRequest(server string, ref string, userId openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "user_id", userId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/jit/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RollbackMigrationsRequest generates requests for V1RollbackMigrations -func NewV1RollbackMigrationsRequest(server string, ref string, params *V1RollbackMigrationsParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "gte", params.Gte, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListMigrationHistoryRequest generates requests for V1ListMigrationHistory -func NewV1ListMigrationHistoryRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ApplyAMigrationRequest calls the generic V1ApplyAMigration builder with application/json body -func NewV1ApplyAMigrationRequest(server string, ref string, params *V1ApplyAMigrationParams, body V1ApplyAMigrationJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ApplyAMigrationRequestWithBody(server, ref, params, "application/json", bodyReader) -} - -// NewV1ApplyAMigrationRequestWithBody generates requests for V1ApplyAMigration with any type of body -func NewV1ApplyAMigrationRequestWithBody(server string, ref string, params *V1ApplyAMigrationParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - if params != nil { - - if params.IdempotencyKey != nil { - var headerParam0 string - - headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - req.Header.Set("Idempotency-Key", headerParam0) - } - - } - - return req, nil -} - -// NewV1UpsertAMigrationRequest calls the generic V1UpsertAMigration builder with application/json body -func NewV1UpsertAMigrationRequest(server string, ref string, params *V1UpsertAMigrationParams, body V1UpsertAMigrationJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpsertAMigrationRequestWithBody(server, ref, params, "application/json", bodyReader) -} - -// NewV1UpsertAMigrationRequestWithBody generates requests for V1UpsertAMigration with any type of body -func NewV1UpsertAMigrationRequestWithBody(server string, ref string, params *V1UpsertAMigrationParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - if params != nil { - - if params.IdempotencyKey != nil { - var headerParam0 string - - headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Idempotency-Key", *params.IdempotencyKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - req.Header.Set("Idempotency-Key", headerParam0) - } - - } - - return req, nil -} - -// NewV1GetAMigrationRequest generates requests for V1GetAMigration -func NewV1GetAMigrationRequest(server string, ref string, version string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "version", version, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1PatchAMigrationRequest calls the generic V1PatchAMigration builder with application/json body -func NewV1PatchAMigrationRequest(server string, ref string, version string, body V1PatchAMigrationJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1PatchAMigrationRequestWithBody(server, ref, version, "application/json", bodyReader) -} - -// NewV1PatchAMigrationRequestWithBody generates requests for V1PatchAMigration with any type of body -func NewV1PatchAMigrationRequestWithBody(server string, ref string, version string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "version", version, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/migrations/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetDatabaseOpenapiRequest generates requests for V1GetDatabaseOpenapi -func NewV1GetDatabaseOpenapiRequest(server string, ref string, params *V1GetDatabaseOpenapiParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/openapi", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Schema != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "schema", *params.Schema, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateDatabasePasswordRequest calls the generic V1UpdateDatabasePassword builder with application/json body -func NewV1UpdateDatabasePasswordRequest(server string, ref string, body V1UpdateDatabasePasswordJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateDatabasePasswordRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateDatabasePasswordRequestWithBody generates requests for V1UpdateDatabasePassword with any type of body -func NewV1UpdateDatabasePasswordRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/password", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1RunAQueryRequest calls the generic V1RunAQuery builder with application/json body -func NewV1RunAQueryRequest(server string, ref string, body V1RunAQueryJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1RunAQueryRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1RunAQueryRequestWithBody generates requests for V1RunAQuery with any type of body -func NewV1RunAQueryRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/query", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ReadOnlyQueryRequest calls the generic V1ReadOnlyQuery builder with application/json body -func NewV1ReadOnlyQueryRequest(server string, ref string, body V1ReadOnlyQueryJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ReadOnlyQueryRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1ReadOnlyQueryRequestWithBody generates requests for V1ReadOnlyQuery with any type of body -func NewV1ReadOnlyQueryRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/query/read-only", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1EnableDatabaseWebhookRequest generates requests for V1EnableDatabaseWebhook -func NewV1EnableDatabaseWebhookRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/database/webhooks/enable", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAllFunctionsRequest generates requests for V1ListAllFunctions -func NewV1ListAllFunctionsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CreateAFunctionRequest calls the generic V1CreateAFunction builder with application/json body -func NewV1CreateAFunctionRequest(server string, ref string, params *V1CreateAFunctionParams, body V1CreateAFunctionJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CreateAFunctionRequestWithBody(server, ref, params, "application/json", bodyReader) -} - -// NewV1CreateAFunctionRequestWithBody generates requests for V1CreateAFunction with any type of body -func NewV1CreateAFunctionRequestWithBody(server string, ref string, params *V1CreateAFunctionParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Slug != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "slug", *params.Slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Name != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.VerifyJwt != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "verify_jwt", *params.VerifyJwt, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ImportMap != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map", *params.ImportMap, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.EntrypointPath != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "entrypoint_path", *params.EntrypointPath, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ImportMapPath != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map_path", *params.ImportMapPath, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.EzbrSha256 != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "ezbr_sha256", *params.EzbrSha256, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1BulkUpdateFunctionsRequest calls the generic V1BulkUpdateFunctions builder with application/json body -func NewV1BulkUpdateFunctionsRequest(server string, ref string, body V1BulkUpdateFunctionsJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1BulkUpdateFunctionsRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1BulkUpdateFunctionsRequestWithBody generates requests for V1BulkUpdateFunctions with any type of body -func NewV1BulkUpdateFunctionsRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeployAFunctionRequestWithBody generates requests for V1DeployAFunction with any type of body -func NewV1DeployAFunctionRequestWithBody(server string, ref string, params *V1DeployAFunctionParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions/deploy", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Slug != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "slug", *params.Slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.BundleOnly != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "bundleOnly", *params.BundleOnly, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteAFunctionRequest generates requests for V1DeleteAFunction -func NewV1DeleteAFunctionRequest(server string, ref string, functionSlug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetAFunctionRequest generates requests for V1GetAFunction -func NewV1GetAFunctionRequest(server string, ref string, functionSlug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateAFunctionRequest calls the generic V1UpdateAFunction builder with application/json body -func NewV1UpdateAFunctionRequest(server string, ref string, functionSlug string, params *V1UpdateAFunctionParams, body V1UpdateAFunctionJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateAFunctionRequestWithBody(server, ref, functionSlug, params, "application/json", bodyReader) -} - -// NewV1UpdateAFunctionRequestWithBody generates requests for V1UpdateAFunction with any type of body -func NewV1UpdateAFunctionRequestWithBody(server string, ref string, functionSlug string, params *V1UpdateAFunctionParams, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Slug != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "slug", *params.Slug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Name != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.VerifyJwt != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "verify_jwt", *params.VerifyJwt, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ImportMap != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map", *params.ImportMap, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.EntrypointPath != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "entrypoint_path", *params.EntrypointPath, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ImportMapPath != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "import_map_path", *params.ImportMapPath, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.EzbrSha256 != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "ezbr_sha256", *params.EzbrSha256, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetAFunctionBodyRequest generates requests for V1GetAFunctionBody -func NewV1GetAFunctionBodyRequest(server string, ref string, functionSlug string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "function_slug", functionSlug, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/functions/%s/body", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetServicesHealthRequest generates requests for V1GetServicesHealth -func NewV1GetServicesHealthRequest(server string, ref string, params *V1GetServicesHealthParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/health", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.Services != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "services", params.Services, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "array", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.TimeoutMs != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "timeout_ms", *params.TimeoutMs, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetJitAccessConfigRequest generates requests for V1GetJitAccessConfig -func NewV1GetJitAccessConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/jit-access", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateJitAccessConfigRequest calls the generic V1UpdateJitAccessConfig builder with application/json body -func NewV1UpdateJitAccessConfigRequest(server string, ref string, body V1UpdateJitAccessConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateJitAccessConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateJitAccessConfigRequestWithBody generates requests for V1UpdateJitAccessConfig with any type of body -func NewV1UpdateJitAccessConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/jit-access", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1DeleteNetworkBansRequest calls the generic V1DeleteNetworkBans builder with application/json body -func NewV1DeleteNetworkBansRequest(server string, ref string, body V1DeleteNetworkBansJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1DeleteNetworkBansRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1DeleteNetworkBansRequestWithBody generates requests for V1DeleteNetworkBans with any type of body -func NewV1DeleteNetworkBansRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-bans", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllNetworkBansRequest generates requests for V1ListAllNetworkBans -func NewV1ListAllNetworkBansRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-bans/retrieve", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAllNetworkBansEnrichedRequest generates requests for V1ListAllNetworkBansEnriched -func NewV1ListAllNetworkBansEnrichedRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-bans/retrieve/enriched", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetNetworkRestrictionsRequest generates requests for V1GetNetworkRestrictions -func NewV1GetNetworkRestrictionsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-restrictions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1PatchNetworkRestrictionsRequest calls the generic V1PatchNetworkRestrictions builder with application/json body -func NewV1PatchNetworkRestrictionsRequest(server string, ref string, body V1PatchNetworkRestrictionsJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1PatchNetworkRestrictionsRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1PatchNetworkRestrictionsRequestWithBody generates requests for V1PatchNetworkRestrictions with any type of body -func NewV1PatchNetworkRestrictionsRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-restrictions", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1UpdateNetworkRestrictionsRequest calls the generic V1UpdateNetworkRestrictions builder with application/json body -func NewV1UpdateNetworkRestrictionsRequest(server string, ref string, body V1UpdateNetworkRestrictionsJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateNetworkRestrictionsRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateNetworkRestrictionsRequestWithBody generates requests for V1UpdateNetworkRestrictions with any type of body -func NewV1UpdateNetworkRestrictionsRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/network-restrictions/apply", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1PauseAProjectRequest generates requests for V1PauseAProject -func NewV1PauseAProjectRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/pause", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetPgsodiumConfigRequest generates requests for V1GetPgsodiumConfig -func NewV1GetPgsodiumConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/pgsodium", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdatePgsodiumConfigRequest calls the generic V1UpdatePgsodiumConfig builder with application/json body -func NewV1UpdatePgsodiumConfigRequest(server string, ref string, body V1UpdatePgsodiumConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdatePgsodiumConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdatePgsodiumConfigRequestWithBody generates requests for V1UpdatePgsodiumConfig with any type of body -func NewV1UpdatePgsodiumConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/pgsodium", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetPostgrestServiceConfigRequest generates requests for V1GetPostgrestServiceConfig -func NewV1GetPostgrestServiceConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/postgrest", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdatePostgrestServiceConfigRequest calls the generic V1UpdatePostgrestServiceConfig builder with application/json body -func NewV1UpdatePostgrestServiceConfigRequest(server string, ref string, body V1UpdatePostgrestServiceConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdatePostgrestServiceConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdatePostgrestServiceConfigRequestWithBody generates requests for V1UpdatePostgrestServiceConfig with any type of body -func NewV1UpdatePostgrestServiceConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/postgrest", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1RemoveAReadReplicaRequest calls the generic V1RemoveAReadReplica builder with application/json body -func NewV1RemoveAReadReplicaRequest(server string, ref string, body V1RemoveAReadReplicaJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1RemoveAReadReplicaRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1RemoveAReadReplicaRequestWithBody generates requests for V1RemoveAReadReplica with any type of body -func NewV1RemoveAReadReplicaRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/read-replicas/remove", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1SetupAReadReplicaRequest calls the generic V1SetupAReadReplica builder with application/json body -func NewV1SetupAReadReplicaRequest(server string, ref string, body V1SetupAReadReplicaJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1SetupAReadReplicaRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1SetupAReadReplicaRequestWithBody generates requests for V1SetupAReadReplica with any type of body -func NewV1SetupAReadReplicaRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/read-replicas/setup", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetReadonlyModeStatusRequest generates requests for V1GetReadonlyModeStatus -func NewV1GetReadonlyModeStatusRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/readonly", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DisableReadonlyModeTemporarilyRequest generates requests for V1DisableReadonlyModeTemporarily -func NewV1DisableReadonlyModeTemporarilyRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/readonly/temporary-disable", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RestartAProjectRequest generates requests for V1RestartAProject -func NewV1RestartAProjectRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/restart", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ListAvailableRestoreVersionsRequest generates requests for V1ListAvailableRestoreVersions -func NewV1ListAvailableRestoreVersionsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/restore", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1RestoreAProjectRequest generates requests for V1RestoreAProject -func NewV1RestoreAProjectRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/restore", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1CancelAProjectRestorationRequest generates requests for V1CancelAProjectRestoration -func NewV1CancelAProjectRestorationRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/restore/cancel", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1BulkDeleteSecretsRequest calls the generic V1BulkDeleteSecrets builder with application/json body -func NewV1BulkDeleteSecretsRequest(server string, ref string, body V1BulkDeleteSecretsJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1BulkDeleteSecretsRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1BulkDeleteSecretsRequestWithBody generates requests for V1BulkDeleteSecrets with any type of body -func NewV1BulkDeleteSecretsRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/secrets", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllSecretsRequest generates requests for V1ListAllSecrets -func NewV1ListAllSecretsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/secrets", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1BulkCreateSecretsRequest calls the generic V1BulkCreateSecrets builder with application/json body -func NewV1BulkCreateSecretsRequest(server string, ref string, body V1BulkCreateSecretsJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1BulkCreateSecretsRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1BulkCreateSecretsRequestWithBody generates requests for V1BulkCreateSecrets with any type of body -func NewV1BulkCreateSecretsRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/secrets", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetSslEnforcementConfigRequest generates requests for V1GetSslEnforcementConfig -func NewV1GetSslEnforcementConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/ssl-enforcement", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpdateSslEnforcementConfigRequest calls the generic V1UpdateSslEnforcementConfig builder with application/json body -func NewV1UpdateSslEnforcementConfigRequest(server string, ref string, body V1UpdateSslEnforcementConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpdateSslEnforcementConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpdateSslEnforcementConfigRequestWithBody generates requests for V1UpdateSslEnforcementConfig with any type of body -func NewV1UpdateSslEnforcementConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/ssl-enforcement", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllBucketsRequest generates requests for V1ListAllBuckets -func NewV1ListAllBucketsRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/storage/buckets", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GenerateTypescriptTypesRequest generates requests for V1GenerateTypescriptTypes -func NewV1GenerateTypescriptTypesRequest(server string, ref string, params *V1GenerateTypescriptTypesParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/types/typescript", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.IncludedSchemas != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "included_schemas", *params.IncludedSchemas, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1UpgradePostgresVersionRequest calls the generic V1UpgradePostgresVersion builder with application/json body -func NewV1UpgradePostgresVersionRequest(server string, ref string, body V1UpgradePostgresVersionJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1UpgradePostgresVersionRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1UpgradePostgresVersionRequestWithBody generates requests for V1UpgradePostgresVersion with any type of body -func NewV1UpgradePostgresVersionRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/upgrade", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1GetPostgresUpgradeEligibilityRequest generates requests for V1GetPostgresUpgradeEligibility -func NewV1GetPostgresUpgradeEligibilityRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/upgrade/eligibility", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetPostgresUpgradeStatusRequest generates requests for V1GetPostgresUpgradeStatus -func NewV1GetPostgresUpgradeStatusRequest(server string, ref string, params *V1GetPostgresUpgradeStatusParams) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/upgrade/status", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.TrackingId != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "tracking_id", *params.TrackingId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1DeactivateVanitySubdomainConfigRequest generates requests for V1DeactivateVanitySubdomainConfig -func NewV1DeactivateVanitySubdomainConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/vanity-subdomain", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetVanitySubdomainConfigRequest generates requests for V1GetVanitySubdomainConfig -func NewV1GetVanitySubdomainConfigRequest(server string, ref string) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/vanity-subdomain", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1ActivateVanitySubdomainConfigRequest calls the generic V1ActivateVanitySubdomainConfig builder with application/json body -func NewV1ActivateVanitySubdomainConfigRequest(server string, ref string, body V1ActivateVanitySubdomainConfigJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1ActivateVanitySubdomainConfigRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1ActivateVanitySubdomainConfigRequestWithBody generates requests for V1ActivateVanitySubdomainConfig with any type of body -func NewV1ActivateVanitySubdomainConfigRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/vanity-subdomain/activate", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1CheckVanitySubdomainAvailabilityRequest calls the generic V1CheckVanitySubdomainAvailability builder with application/json body -func NewV1CheckVanitySubdomainAvailabilityRequest(server string, ref string, body V1CheckVanitySubdomainAvailabilityJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewV1CheckVanitySubdomainAvailabilityRequestWithBody(server, ref, "application/json", bodyReader) -} - -// NewV1CheckVanitySubdomainAvailabilityRequestWithBody generates requests for V1CheckVanitySubdomainAvailability with any type of body -func NewV1CheckVanitySubdomainAvailabilityRequestWithBody(server string, ref string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "ref", ref, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/projects/%s/vanity-subdomain/check-availability", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewV1ListAllSnippetsRequest generates requests for V1ListAllSnippets -func NewV1ListAllSnippetsRequest(server string, params *V1ListAllSnippetsParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/snippets") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if params.ProjectRef != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "project_ref", *params.ProjectRef, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Cursor != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.Limit != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.SortBy != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "sort_by", *params.SortBy, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.SortOrder != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "sort_order", *params.SortOrder, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewV1GetASnippetRequest generates requests for V1GetASnippet -func NewV1GetASnippetRequest(server string, id openapi_types.UUID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/snippets/%s", pathParam0) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { - for _, r := range c.RequestEditors { - if err := r(ctx, req); err != nil { - return err - } - } - for _, r := range additionalEditors { - if err := r(ctx, req); err != nil { - return err - } - } - return nil -} - -// ClientWithResponses builds on ClientInterface to offer response payloads -type ClientWithResponses struct { - ClientInterface -} - -// NewClientWithResponses creates a new ClientWithResponses, which wraps -// Client with return type handling -func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { - client, err := NewClient(server, opts...) - if err != nil { - return nil, err - } - return &ClientWithResponses{client}, nil -} - -// WithBaseURL overrides the baseURL. -func WithBaseURL(baseURL string) ClientOption { - return func(c *Client) error { - newBaseURL, err := url.Parse(baseURL) - if err != nil { - return err - } - c.Server = newBaseURL.String() - return nil - } -} - -// ClientWithResponsesInterface is the interface specification for the client with responses above. -type ClientWithResponsesInterface interface { - // V1DeleteABranchWithResponse request - V1DeleteABranchWithResponse(ctx context.Context, branchIdOrRef string, params *V1DeleteABranchParams, reqEditors ...RequestEditorFn) (*V1DeleteABranchResponse, error) - - // V1GetABranchConfigWithResponse request - V1GetABranchConfigWithResponse(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*V1GetABranchConfigResponse, error) - - // V1UpdateABranchConfigWithBodyWithResponse request with any body - V1UpdateABranchConfigWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateABranchConfigResponse, error) - - V1UpdateABranchConfigWithResponse(ctx context.Context, branchIdOrRef string, body V1UpdateABranchConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateABranchConfigResponse, error) - - // V1DiffABranchWithResponse request - V1DiffABranchWithResponse(ctx context.Context, branchIdOrRef string, params *V1DiffABranchParams, reqEditors ...RequestEditorFn) (*V1DiffABranchResponse, error) - - // V1MergeABranchWithBodyWithResponse request with any body - V1MergeABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1MergeABranchResponse, error) - - V1MergeABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1MergeABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1MergeABranchResponse, error) - - // V1PushABranchWithBodyWithResponse request with any body - V1PushABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PushABranchResponse, error) - - V1PushABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1PushABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PushABranchResponse, error) - - // V1ResetABranchWithBodyWithResponse request with any body - V1ResetABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ResetABranchResponse, error) - - V1ResetABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1ResetABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ResetABranchResponse, error) - - // V1RestoreABranchWithResponse request - V1RestoreABranchWithResponse(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*V1RestoreABranchResponse, error) - - // V1AuthorizeUserWithResponse request - V1AuthorizeUserWithResponse(ctx context.Context, params *V1AuthorizeUserParams, reqEditors ...RequestEditorFn) (*V1AuthorizeUserResponse, error) - - // V1OauthAuthorizeProjectClaimWithResponse request - V1OauthAuthorizeProjectClaimWithResponse(ctx context.Context, params *V1OauthAuthorizeProjectClaimParams, reqEditors ...RequestEditorFn) (*V1OauthAuthorizeProjectClaimResponse, error) - - // V1RevokeTokenWithBodyWithResponse request with any body - V1RevokeTokenWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RevokeTokenResponse, error) - - V1RevokeTokenWithResponse(ctx context.Context, body V1RevokeTokenJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RevokeTokenResponse, error) - - // V1ExchangeOauthTokenWithBodyWithResponse request with any body - V1ExchangeOauthTokenWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ExchangeOauthTokenResponse, error) - - V1ExchangeOauthTokenWithFormdataBodyWithResponse(ctx context.Context, body V1ExchangeOauthTokenFormdataRequestBody, reqEditors ...RequestEditorFn) (*V1ExchangeOauthTokenResponse, error) - - // V1ListAllOrganizationsWithResponse request - V1ListAllOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllOrganizationsResponse, error) - - // V1CreateAnOrganizationWithBodyWithResponse request with any body - V1CreateAnOrganizationWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAnOrganizationResponse, error) - - V1CreateAnOrganizationWithResponse(ctx context.Context, body V1CreateAnOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAnOrganizationResponse, error) - - // V1GetAnOrganizationWithResponse request - V1GetAnOrganizationWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetAnOrganizationResponse, error) - - // V1GetOrganizationEntitlementsWithResponse request - V1GetOrganizationEntitlementsWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetOrganizationEntitlementsResponse, error) - - // V1ListOrganizationMembersWithResponse request - V1ListOrganizationMembersWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1ListOrganizationMembersResponse, error) - - // V1GetOrganizationProjectClaimWithResponse request - V1GetOrganizationProjectClaimWithResponse(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*V1GetOrganizationProjectClaimResponse, error) - - // V1ClaimProjectForOrganizationWithResponse request - V1ClaimProjectForOrganizationWithResponse(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*V1ClaimProjectForOrganizationResponse, error) - - // V1GetAllProjectsForOrganizationWithResponse request - V1GetAllProjectsForOrganizationWithResponse(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*V1GetAllProjectsForOrganizationResponse, error) - - // V1GetProfileWithResponse request - V1GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1GetProfileResponse, error) - - // V1ListAllProjectsWithResponse request - V1ListAllProjectsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllProjectsResponse, error) - - // V1CreateAProjectWithBodyWithResponse request with any body - V1CreateAProjectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAProjectResponse, error) - - V1CreateAProjectWithResponse(ctx context.Context, body V1CreateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAProjectResponse, error) - - // V1GetAvailableRegionsWithResponse request - V1GetAvailableRegionsWithResponse(ctx context.Context, params *V1GetAvailableRegionsParams, reqEditors ...RequestEditorFn) (*V1GetAvailableRegionsResponse, error) - - // V1DeleteAProjectWithResponse request - V1DeleteAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteAProjectResponse, error) - - // V1GetProjectWithResponse request - V1GetProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectResponse, error) - - // V1UpdateAProjectWithBodyWithResponse request with any body - V1UpdateAProjectWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAProjectResponse, error) - - V1UpdateAProjectWithResponse(ctx context.Context, ref string, body V1UpdateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAProjectResponse, error) - - // V1ListActionRunsWithResponse request - V1ListActionRunsWithResponse(ctx context.Context, ref string, params *V1ListActionRunsParams, reqEditors ...RequestEditorFn) (*V1ListActionRunsResponse, error) - - // V1CountActionRunsWithResponse request - V1CountActionRunsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CountActionRunsResponse, error) - - // V1GetActionRunWithResponse request - V1GetActionRunWithResponse(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*V1GetActionRunResponse, error) - - // V1GetActionRunLogsWithResponse request - V1GetActionRunLogsWithResponse(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*V1GetActionRunLogsResponse, error) - - // V1UpdateActionRunStatusWithBodyWithResponse request with any body - V1UpdateActionRunStatusWithBodyWithResponse(ctx context.Context, ref string, runId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateActionRunStatusResponse, error) - - V1UpdateActionRunStatusWithResponse(ctx context.Context, ref string, runId string, body V1UpdateActionRunStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateActionRunStatusResponse, error) - - // V1GetPerformanceAdvisorsWithResponse request - V1GetPerformanceAdvisorsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPerformanceAdvisorsResponse, error) - - // V1GetSecurityAdvisorsWithResponse request - V1GetSecurityAdvisorsWithResponse(ctx context.Context, ref string, params *V1GetSecurityAdvisorsParams, reqEditors ...RequestEditorFn) (*V1GetSecurityAdvisorsResponse, error) - - // V1GetProjectFunctionCombinedStatsWithResponse request - V1GetProjectFunctionCombinedStatsWithResponse(ctx context.Context, ref string, params *V1GetProjectFunctionCombinedStatsParams, reqEditors ...RequestEditorFn) (*V1GetProjectFunctionCombinedStatsResponse, error) - - // V1GetProjectLogsWithResponse request - V1GetProjectLogsWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*V1GetProjectLogsResponse, error) - - // V1GetProjectLogsAllWithResponse request - V1GetProjectLogsAllWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectLogsAllResponse, error) - - // V1ScrapeProjectMetricsWithResponse request - V1ScrapeProjectMetricsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ScrapeProjectMetricsResponse, error) - - // V1GetProjectUsageApiCountWithResponse request - V1GetProjectUsageApiCountWithResponse(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*V1GetProjectUsageApiCountResponse, error) - - // V1GetProjectUsageRequestCountWithResponse request - V1GetProjectUsageRequestCountWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectUsageRequestCountResponse, error) - - // V1GetProjectApiKeysWithResponse request - V1GetProjectApiKeysWithResponse(ctx context.Context, ref string, params *V1GetProjectApiKeysParams, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeysResponse, error) - - // V1CreateProjectApiKeyWithBodyWithResponse request with any body - V1CreateProjectApiKeyWithBodyWithResponse(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectApiKeyResponse, error) - - V1CreateProjectApiKeyWithResponse(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, body V1CreateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectApiKeyResponse, error) - - // V1GetProjectLegacyApiKeysWithResponse request - V1GetProjectLegacyApiKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectLegacyApiKeysResponse, error) - - // V1UpdateProjectLegacyApiKeysWithResponse request - V1UpdateProjectLegacyApiKeysWithResponse(ctx context.Context, ref string, params *V1UpdateProjectLegacyApiKeysParams, reqEditors ...RequestEditorFn) (*V1UpdateProjectLegacyApiKeysResponse, error) - - // V1DeleteProjectApiKeyWithResponse request - V1DeleteProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1DeleteProjectApiKeyParams, reqEditors ...RequestEditorFn) (*V1DeleteProjectApiKeyResponse, error) - - // V1GetProjectApiKeyWithResponse request - V1GetProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1GetProjectApiKeyParams, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeyResponse, error) - - // V1UpdateProjectApiKeyWithBodyWithResponse request with any body - V1UpdateProjectApiKeyWithBodyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateProjectApiKeyResponse, error) - - V1UpdateProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, body V1UpdateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateProjectApiKeyResponse, error) - - // V1ListProjectAddonsWithResponse request - V1ListProjectAddonsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListProjectAddonsResponse, error) - - // V1ApplyProjectAddonWithBodyWithResponse request with any body - V1ApplyProjectAddonWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ApplyProjectAddonResponse, error) - - V1ApplyProjectAddonWithResponse(ctx context.Context, ref string, body V1ApplyProjectAddonJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ApplyProjectAddonResponse, error) - - // V1RemoveProjectAddonWithResponse request - V1RemoveProjectAddonWithResponse(ctx context.Context, ref string, addonVariant struct { - union json.RawMessage - }, reqEditors ...RequestEditorFn) (*V1RemoveProjectAddonResponse, error) - - // V1DisablePreviewBranchingWithResponse request - V1DisablePreviewBranchingWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DisablePreviewBranchingResponse, error) - - // V1ListAllBranchesWithResponse request - V1ListAllBranchesWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBranchesResponse, error) - - // V1CreateABranchWithBodyWithResponse request with any body - V1CreateABranchWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateABranchResponse, error) - - V1CreateABranchWithResponse(ctx context.Context, ref string, body V1CreateABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateABranchResponse, error) - - // V1GetABranchWithResponse request - V1GetABranchWithResponse(ctx context.Context, ref string, name string, reqEditors ...RequestEditorFn) (*V1GetABranchResponse, error) - - // V1DeleteProjectClaimTokenWithResponse request - V1DeleteProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteProjectClaimTokenResponse, error) - - // V1GetProjectClaimTokenWithResponse request - V1GetProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectClaimTokenResponse, error) - - // V1CreateProjectClaimTokenWithResponse request - V1CreateProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CreateProjectClaimTokenResponse, error) - - // V1DeleteLoginRolesWithResponse request - V1DeleteLoginRolesWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteLoginRolesResponse, error) - - // V1CreateLoginRoleWithBodyWithResponse request with any body - V1CreateLoginRoleWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateLoginRoleResponse, error) - - V1CreateLoginRoleWithResponse(ctx context.Context, ref string, body V1CreateLoginRoleJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateLoginRoleResponse, error) - - // V1GetAuthServiceConfigWithResponse request - V1GetAuthServiceConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetAuthServiceConfigResponse, error) - - // V1UpdateAuthServiceConfigWithBodyWithResponse request with any body - V1UpdateAuthServiceConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAuthServiceConfigResponse, error) - - V1UpdateAuthServiceConfigWithResponse(ctx context.Context, ref string, body V1UpdateAuthServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAuthServiceConfigResponse, error) - - // V1GetProjectSigningKeysWithResponse request - V1GetProjectSigningKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectSigningKeysResponse, error) - - // V1CreateProjectSigningKeyWithBodyWithResponse request with any body - V1CreateProjectSigningKeyWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectSigningKeyResponse, error) - - V1CreateProjectSigningKeyWithResponse(ctx context.Context, ref string, body V1CreateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectSigningKeyResponse, error) - - // V1GetLegacySigningKeyWithResponse request - V1GetLegacySigningKeyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetLegacySigningKeyResponse, error) - - // V1CreateLegacySigningKeyWithResponse request - V1CreateLegacySigningKeyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CreateLegacySigningKeyResponse, error) - - // V1RemoveProjectSigningKeyWithResponse request - V1RemoveProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1RemoveProjectSigningKeyResponse, error) - - // V1GetProjectSigningKeyWithResponse request - V1GetProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetProjectSigningKeyResponse, error) - - // V1UpdateProjectSigningKeyWithBodyWithResponse request with any body - V1UpdateProjectSigningKeyWithBodyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateProjectSigningKeyResponse, error) - - V1UpdateProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, body V1UpdateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateProjectSigningKeyResponse, error) - - // V1ListAllSsoProviderWithResponse request - V1ListAllSsoProviderWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllSsoProviderResponse, error) - - // V1CreateASsoProviderWithBodyWithResponse request with any body - V1CreateASsoProviderWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateASsoProviderResponse, error) - - V1CreateASsoProviderWithResponse(ctx context.Context, ref string, body V1CreateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateASsoProviderResponse, error) - - // V1DeleteASsoProviderWithResponse request - V1DeleteASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteASsoProviderResponse, error) - - // V1GetASsoProviderWithResponse request - V1GetASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetASsoProviderResponse, error) - - // V1UpdateASsoProviderWithBodyWithResponse request with any body - V1UpdateASsoProviderWithBodyWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateASsoProviderResponse, error) - - V1UpdateASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, body V1UpdateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateASsoProviderResponse, error) - - // V1ListProjectTpaIntegrationsWithResponse request - V1ListProjectTpaIntegrationsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListProjectTpaIntegrationsResponse, error) - - // V1CreateProjectTpaIntegrationWithBodyWithResponse request with any body - V1CreateProjectTpaIntegrationWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectTpaIntegrationResponse, error) - - V1CreateProjectTpaIntegrationWithResponse(ctx context.Context, ref string, body V1CreateProjectTpaIntegrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectTpaIntegrationResponse, error) - - // V1DeleteProjectTpaIntegrationWithResponse request - V1DeleteProjectTpaIntegrationWithResponse(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteProjectTpaIntegrationResponse, error) - - // V1GetProjectTpaIntegrationWithResponse request - V1GetProjectTpaIntegrationWithResponse(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetProjectTpaIntegrationResponse, error) - - // V1GetProjectPgbouncerConfigWithResponse request - V1GetProjectPgbouncerConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectPgbouncerConfigResponse, error) - - // V1GetPoolerConfigWithResponse request - V1GetPoolerConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPoolerConfigResponse, error) - - // V1UpdatePoolerConfigWithBodyWithResponse request with any body - V1UpdatePoolerConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePoolerConfigResponse, error) - - V1UpdatePoolerConfigWithResponse(ctx context.Context, ref string, body V1UpdatePoolerConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePoolerConfigResponse, error) - - // V1GetPostgresConfigWithResponse request - V1GetPostgresConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgresConfigResponse, error) - - // V1UpdatePostgresConfigWithBodyWithResponse request with any body - V1UpdatePostgresConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePostgresConfigResponse, error) - - V1UpdatePostgresConfigWithResponse(ctx context.Context, ref string, body V1UpdatePostgresConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePostgresConfigResponse, error) - - // V1GetDatabaseDiskWithResponse request - V1GetDatabaseDiskWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDatabaseDiskResponse, error) - - // V1ModifyDatabaseDiskWithBodyWithResponse request with any body - V1ModifyDatabaseDiskWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ModifyDatabaseDiskResponse, error) - - V1ModifyDatabaseDiskWithResponse(ctx context.Context, ref string, body V1ModifyDatabaseDiskJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ModifyDatabaseDiskResponse, error) - - // V1GetProjectDiskAutoscaleConfigWithResponse request - V1GetProjectDiskAutoscaleConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectDiskAutoscaleConfigResponse, error) - - // V1GetDiskUtilizationWithResponse request - V1GetDiskUtilizationWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDiskUtilizationResponse, error) - - // V1GetRealtimeConfigWithResponse request - V1GetRealtimeConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetRealtimeConfigResponse, error) - - // V1UpdateRealtimeConfigWithBodyWithResponse request with any body - V1UpdateRealtimeConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateRealtimeConfigResponse, error) - - V1UpdateRealtimeConfigWithResponse(ctx context.Context, ref string, body V1UpdateRealtimeConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateRealtimeConfigResponse, error) - - // V1ShutdownRealtimeWithResponse request - V1ShutdownRealtimeWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ShutdownRealtimeResponse, error) - - // V1GetStorageConfigWithResponse request - V1GetStorageConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetStorageConfigResponse, error) - - // V1UpdateStorageConfigWithBodyWithResponse request with any body - V1UpdateStorageConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateStorageConfigResponse, error) - - V1UpdateStorageConfigWithResponse(ctx context.Context, ref string, body V1UpdateStorageConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateStorageConfigResponse, error) - - // V1DeleteHostnameConfigWithResponse request - V1DeleteHostnameConfigWithResponse(ctx context.Context, ref string, params *V1DeleteHostnameConfigParams, reqEditors ...RequestEditorFn) (*V1DeleteHostnameConfigResponse, error) - - // V1GetHostnameConfigWithResponse request - V1GetHostnameConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetHostnameConfigResponse, error) - - // V1ActivateCustomHostnameWithResponse request - V1ActivateCustomHostnameWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ActivateCustomHostnameResponse, error) - - // V1UpdateHostnameConfigWithBodyWithResponse request with any body - V1UpdateHostnameConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateHostnameConfigResponse, error) - - V1UpdateHostnameConfigWithResponse(ctx context.Context, ref string, body V1UpdateHostnameConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateHostnameConfigResponse, error) - - // V1VerifyDnsConfigWithResponse request - V1VerifyDnsConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1VerifyDnsConfigResponse, error) - - // V1ListAllBackupsWithResponse request - V1ListAllBackupsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBackupsResponse, error) - - // V1RestorePhysicalBackupWithBodyWithResponse request with any body - V1RestorePhysicalBackupWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RestorePhysicalBackupResponse, error) - - V1RestorePhysicalBackupWithResponse(ctx context.Context, ref string, body V1RestorePhysicalBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RestorePhysicalBackupResponse, error) - - // V1RestorePitrBackupWithBodyWithResponse request with any body - V1RestorePitrBackupWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RestorePitrBackupResponse, error) - - V1RestorePitrBackupWithResponse(ctx context.Context, ref string, body V1RestorePitrBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RestorePitrBackupResponse, error) - - // V1GetRestorePointWithResponse request - V1GetRestorePointWithResponse(ctx context.Context, ref string, params *V1GetRestorePointParams, reqEditors ...RequestEditorFn) (*V1GetRestorePointResponse, error) - - // V1CreateRestorePointWithBodyWithResponse request with any body - V1CreateRestorePointWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateRestorePointResponse, error) - - V1CreateRestorePointWithResponse(ctx context.Context, ref string, body V1CreateRestorePointJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateRestorePointResponse, error) - - // V1GetBackupScheduleWithResponse request - V1GetBackupScheduleWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetBackupScheduleResponse, error) - - // V1UpdateBackupScheduleWithBodyWithResponse request with any body - V1UpdateBackupScheduleWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateBackupScheduleResponse, error) - - V1UpdateBackupScheduleWithResponse(ctx context.Context, ref string, body V1UpdateBackupScheduleJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateBackupScheduleResponse, error) - - // V1UndoWithBodyWithResponse request with any body - V1UndoWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UndoResponse, error) - - V1UndoWithResponse(ctx context.Context, ref string, body V1UndoJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UndoResponse, error) - - // V1GetDatabaseMetadataWithResponse request - V1GetDatabaseMetadataWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDatabaseMetadataResponse, error) - - // V1GetJitAccessWithResponse request - V1GetJitAccessWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetJitAccessResponse, error) - - // V1AuthorizeJitAccessWithBodyWithResponse request with any body - V1AuthorizeJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1AuthorizeJitAccessResponse, error) - - V1AuthorizeJitAccessWithResponse(ctx context.Context, ref string, body V1AuthorizeJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1AuthorizeJitAccessResponse, error) - - // V1UpdateJitAccessWithBodyWithResponse request with any body - V1UpdateJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessResponse, error) - - V1UpdateJitAccessWithResponse(ctx context.Context, ref string, body V1UpdateJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessResponse, error) - - // V1InviteExternalJitAccessWithBodyWithResponse request with any body - V1InviteExternalJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1InviteExternalJitAccessResponse, error) - - V1InviteExternalJitAccessWithResponse(ctx context.Context, ref string, body V1InviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1InviteExternalJitAccessResponse, error) - - // V1AcceptInviteExternalJitAccessWithBodyWithResponse request with any body - V1AcceptInviteExternalJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1AcceptInviteExternalJitAccessResponse, error) - - V1AcceptInviteExternalJitAccessWithResponse(ctx context.Context, ref string, body V1AcceptInviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1AcceptInviteExternalJitAccessResponse, error) - - // V1DeleteInviteExternalJitAccessWithResponse request - V1DeleteInviteExternalJitAccessWithResponse(ctx context.Context, ref string, inviteId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteInviteExternalJitAccessResponse, error) - - // V1ListJitAccessWithResponse request - V1ListJitAccessWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListJitAccessResponse, error) - - // V1DeleteJitAccessWithResponse request - V1DeleteJitAccessWithResponse(ctx context.Context, ref string, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteJitAccessResponse, error) - - // V1RollbackMigrationsWithResponse request - V1RollbackMigrationsWithResponse(ctx context.Context, ref string, params *V1RollbackMigrationsParams, reqEditors ...RequestEditorFn) (*V1RollbackMigrationsResponse, error) - - // V1ListMigrationHistoryWithResponse request - V1ListMigrationHistoryWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListMigrationHistoryResponse, error) - - // V1ApplyAMigrationWithBodyWithResponse request with any body - V1ApplyAMigrationWithBodyWithResponse(ctx context.Context, ref string, params *V1ApplyAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ApplyAMigrationResponse, error) - - V1ApplyAMigrationWithResponse(ctx context.Context, ref string, params *V1ApplyAMigrationParams, body V1ApplyAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ApplyAMigrationResponse, error) - - // V1UpsertAMigrationWithBodyWithResponse request with any body - V1UpsertAMigrationWithBodyWithResponse(ctx context.Context, ref string, params *V1UpsertAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpsertAMigrationResponse, error) - - V1UpsertAMigrationWithResponse(ctx context.Context, ref string, params *V1UpsertAMigrationParams, body V1UpsertAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpsertAMigrationResponse, error) - - // V1GetAMigrationWithResponse request - V1GetAMigrationWithResponse(ctx context.Context, ref string, version string, reqEditors ...RequestEditorFn) (*V1GetAMigrationResponse, error) - - // V1PatchAMigrationWithBodyWithResponse request with any body - V1PatchAMigrationWithBodyWithResponse(ctx context.Context, ref string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PatchAMigrationResponse, error) - - V1PatchAMigrationWithResponse(ctx context.Context, ref string, version string, body V1PatchAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PatchAMigrationResponse, error) - - // V1GetDatabaseOpenapiWithResponse request - V1GetDatabaseOpenapiWithResponse(ctx context.Context, ref string, params *V1GetDatabaseOpenapiParams, reqEditors ...RequestEditorFn) (*V1GetDatabaseOpenapiResponse, error) - - // V1UpdateDatabasePasswordWithBodyWithResponse request with any body - V1UpdateDatabasePasswordWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateDatabasePasswordResponse, error) - - V1UpdateDatabasePasswordWithResponse(ctx context.Context, ref string, body V1UpdateDatabasePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateDatabasePasswordResponse, error) - - // V1RunAQueryWithBodyWithResponse request with any body - V1RunAQueryWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RunAQueryResponse, error) - - V1RunAQueryWithResponse(ctx context.Context, ref string, body V1RunAQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RunAQueryResponse, error) - - // V1ReadOnlyQueryWithBodyWithResponse request with any body - V1ReadOnlyQueryWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ReadOnlyQueryResponse, error) - - V1ReadOnlyQueryWithResponse(ctx context.Context, ref string, body V1ReadOnlyQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ReadOnlyQueryResponse, error) - - // V1EnableDatabaseWebhookWithResponse request - V1EnableDatabaseWebhookWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1EnableDatabaseWebhookResponse, error) - - // V1ListAllFunctionsWithResponse request - V1ListAllFunctionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllFunctionsResponse, error) - - // V1CreateAFunctionWithBodyWithResponse request with any body - V1CreateAFunctionWithBodyWithResponse(ctx context.Context, ref string, params *V1CreateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAFunctionResponse, error) - - V1CreateAFunctionWithResponse(ctx context.Context, ref string, params *V1CreateAFunctionParams, body V1CreateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAFunctionResponse, error) - - // V1BulkUpdateFunctionsWithBodyWithResponse request with any body - V1BulkUpdateFunctionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkUpdateFunctionsResponse, error) - - V1BulkUpdateFunctionsWithResponse(ctx context.Context, ref string, body V1BulkUpdateFunctionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkUpdateFunctionsResponse, error) - - // V1DeployAFunctionWithBodyWithResponse request with any body - V1DeployAFunctionWithBodyWithResponse(ctx context.Context, ref string, params *V1DeployAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1DeployAFunctionResponse, error) - - // V1DeleteAFunctionWithResponse request - V1DeleteAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1DeleteAFunctionResponse, error) - - // V1GetAFunctionWithResponse request - V1GetAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1GetAFunctionResponse, error) - - // V1UpdateAFunctionWithBodyWithResponse request with any body - V1UpdateAFunctionWithBodyWithResponse(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAFunctionResponse, error) - - V1UpdateAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, body V1UpdateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAFunctionResponse, error) - - // V1GetAFunctionBodyWithResponse request - V1GetAFunctionBodyWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1GetAFunctionBodyResponse, error) - - // V1GetServicesHealthWithResponse request - V1GetServicesHealthWithResponse(ctx context.Context, ref string, params *V1GetServicesHealthParams, reqEditors ...RequestEditorFn) (*V1GetServicesHealthResponse, error) - - // V1GetJitAccessConfigWithResponse request - V1GetJitAccessConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetJitAccessConfigResponse, error) - - // V1UpdateJitAccessConfigWithBodyWithResponse request with any body - V1UpdateJitAccessConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessConfigResponse, error) - - V1UpdateJitAccessConfigWithResponse(ctx context.Context, ref string, body V1UpdateJitAccessConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessConfigResponse, error) - - // V1DeleteNetworkBansWithBodyWithResponse request with any body - V1DeleteNetworkBansWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1DeleteNetworkBansResponse, error) - - V1DeleteNetworkBansWithResponse(ctx context.Context, ref string, body V1DeleteNetworkBansJSONRequestBody, reqEditors ...RequestEditorFn) (*V1DeleteNetworkBansResponse, error) - - // V1ListAllNetworkBansWithResponse request - V1ListAllNetworkBansWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllNetworkBansResponse, error) - - // V1ListAllNetworkBansEnrichedWithResponse request - V1ListAllNetworkBansEnrichedWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllNetworkBansEnrichedResponse, error) - - // V1GetNetworkRestrictionsWithResponse request - V1GetNetworkRestrictionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetNetworkRestrictionsResponse, error) - - // V1PatchNetworkRestrictionsWithBodyWithResponse request with any body - V1PatchNetworkRestrictionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PatchNetworkRestrictionsResponse, error) - - V1PatchNetworkRestrictionsWithResponse(ctx context.Context, ref string, body V1PatchNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PatchNetworkRestrictionsResponse, error) - - // V1UpdateNetworkRestrictionsWithBodyWithResponse request with any body - V1UpdateNetworkRestrictionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateNetworkRestrictionsResponse, error) - - V1UpdateNetworkRestrictionsWithResponse(ctx context.Context, ref string, body V1UpdateNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateNetworkRestrictionsResponse, error) - - // V1PauseAProjectWithResponse request - V1PauseAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1PauseAProjectResponse, error) - - // V1GetPgsodiumConfigWithResponse request - V1GetPgsodiumConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPgsodiumConfigResponse, error) - - // V1UpdatePgsodiumConfigWithBodyWithResponse request with any body - V1UpdatePgsodiumConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePgsodiumConfigResponse, error) - - V1UpdatePgsodiumConfigWithResponse(ctx context.Context, ref string, body V1UpdatePgsodiumConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePgsodiumConfigResponse, error) - - // V1GetPostgrestServiceConfigWithResponse request - V1GetPostgrestServiceConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgrestServiceConfigResponse, error) - - // V1UpdatePostgrestServiceConfigWithBodyWithResponse request with any body - V1UpdatePostgrestServiceConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePostgrestServiceConfigResponse, error) - - V1UpdatePostgrestServiceConfigWithResponse(ctx context.Context, ref string, body V1UpdatePostgrestServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePostgrestServiceConfigResponse, error) - - // V1RemoveAReadReplicaWithBodyWithResponse request with any body - V1RemoveAReadReplicaWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RemoveAReadReplicaResponse, error) - - V1RemoveAReadReplicaWithResponse(ctx context.Context, ref string, body V1RemoveAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RemoveAReadReplicaResponse, error) - - // V1SetupAReadReplicaWithBodyWithResponse request with any body - V1SetupAReadReplicaWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1SetupAReadReplicaResponse, error) - - V1SetupAReadReplicaWithResponse(ctx context.Context, ref string, body V1SetupAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*V1SetupAReadReplicaResponse, error) - - // V1GetReadonlyModeStatusWithResponse request - V1GetReadonlyModeStatusWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetReadonlyModeStatusResponse, error) - - // V1DisableReadonlyModeTemporarilyWithResponse request - V1DisableReadonlyModeTemporarilyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DisableReadonlyModeTemporarilyResponse, error) - - // V1RestartAProjectWithResponse request - V1RestartAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestartAProjectResponse, error) - - // V1ListAvailableRestoreVersionsWithResponse request - V1ListAvailableRestoreVersionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAvailableRestoreVersionsResponse, error) - - // V1RestoreAProjectWithResponse request - V1RestoreAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestoreAProjectResponse, error) - - // V1CancelAProjectRestorationWithResponse request - V1CancelAProjectRestorationWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CancelAProjectRestorationResponse, error) - - // V1BulkDeleteSecretsWithBodyWithResponse request with any body - V1BulkDeleteSecretsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkDeleteSecretsResponse, error) - - V1BulkDeleteSecretsWithResponse(ctx context.Context, ref string, body V1BulkDeleteSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkDeleteSecretsResponse, error) - - // V1ListAllSecretsWithResponse request - V1ListAllSecretsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllSecretsResponse, error) - - // V1BulkCreateSecretsWithBodyWithResponse request with any body - V1BulkCreateSecretsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkCreateSecretsResponse, error) - - V1BulkCreateSecretsWithResponse(ctx context.Context, ref string, body V1BulkCreateSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkCreateSecretsResponse, error) - - // V1GetSslEnforcementConfigWithResponse request - V1GetSslEnforcementConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetSslEnforcementConfigResponse, error) - - // V1UpdateSslEnforcementConfigWithBodyWithResponse request with any body - V1UpdateSslEnforcementConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateSslEnforcementConfigResponse, error) - - V1UpdateSslEnforcementConfigWithResponse(ctx context.Context, ref string, body V1UpdateSslEnforcementConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateSslEnforcementConfigResponse, error) - - // V1ListAllBucketsWithResponse request - V1ListAllBucketsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBucketsResponse, error) - - // V1GenerateTypescriptTypesWithResponse request - V1GenerateTypescriptTypesWithResponse(ctx context.Context, ref string, params *V1GenerateTypescriptTypesParams, reqEditors ...RequestEditorFn) (*V1GenerateTypescriptTypesResponse, error) - - // V1UpgradePostgresVersionWithBodyWithResponse request with any body - V1UpgradePostgresVersionWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpgradePostgresVersionResponse, error) - - V1UpgradePostgresVersionWithResponse(ctx context.Context, ref string, body V1UpgradePostgresVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpgradePostgresVersionResponse, error) - - // V1GetPostgresUpgradeEligibilityWithResponse request - V1GetPostgresUpgradeEligibilityWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgresUpgradeEligibilityResponse, error) - - // V1GetPostgresUpgradeStatusWithResponse request - V1GetPostgresUpgradeStatusWithResponse(ctx context.Context, ref string, params *V1GetPostgresUpgradeStatusParams, reqEditors ...RequestEditorFn) (*V1GetPostgresUpgradeStatusResponse, error) - - // V1DeactivateVanitySubdomainConfigWithResponse request - V1DeactivateVanitySubdomainConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeactivateVanitySubdomainConfigResponse, error) - - // V1GetVanitySubdomainConfigWithResponse request - V1GetVanitySubdomainConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetVanitySubdomainConfigResponse, error) - - // V1ActivateVanitySubdomainConfigWithBodyWithResponse request with any body - V1ActivateVanitySubdomainConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ActivateVanitySubdomainConfigResponse, error) - - V1ActivateVanitySubdomainConfigWithResponse(ctx context.Context, ref string, body V1ActivateVanitySubdomainConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ActivateVanitySubdomainConfigResponse, error) - - // V1CheckVanitySubdomainAvailabilityWithBodyWithResponse request with any body - V1CheckVanitySubdomainAvailabilityWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CheckVanitySubdomainAvailabilityResponse, error) - - V1CheckVanitySubdomainAvailabilityWithResponse(ctx context.Context, ref string, body V1CheckVanitySubdomainAvailabilityJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CheckVanitySubdomainAvailabilityResponse, error) - - // V1ListAllSnippetsWithResponse request - V1ListAllSnippetsWithResponse(ctx context.Context, params *V1ListAllSnippetsParams, reqEditors ...RequestEditorFn) (*V1ListAllSnippetsResponse, error) - - // V1GetASnippetWithResponse request - V1GetASnippetWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetASnippetResponse, error) -} - -type V1DeleteABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BranchDeleteResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetABranchConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BranchDetailResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetABranchConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetABranchConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetABranchConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateABranchConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BranchResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateABranchConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateABranchConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateABranchConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DiffABranchResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DiffABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DiffABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DiffABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1MergeABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *BranchUpdateResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1MergeABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1MergeABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1MergeABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1PushABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *BranchUpdateResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1PushABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1PushABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1PushABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ResetABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *BranchUpdateResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ResetABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ResetABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ResetABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RestoreABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *BranchRestoreResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1RestoreABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RestoreABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RestoreABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1AuthorizeUserResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1AuthorizeUserResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1AuthorizeUserResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1AuthorizeUserResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1OauthAuthorizeProjectClaimResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1OauthAuthorizeProjectClaimResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1OauthAuthorizeProjectClaimResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1OauthAuthorizeProjectClaimResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RevokeTokenResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RevokeTokenResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RevokeTokenResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RevokeTokenResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ExchangeOauthTokenResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OAuthTokenResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ExchangeOauthTokenResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ExchangeOauthTokenResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ExchangeOauthTokenResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllOrganizationsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]OrganizationResponseV1Output -} - -// Status returns HTTPResponse.Status -func (r V1ListAllOrganizationsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllOrganizationsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllOrganizationsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateAnOrganizationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *OrganizationResponseV1Output -} - -// Status returns HTTPResponse.Status -func (r V1CreateAnOrganizationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateAnOrganizationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateAnOrganizationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAnOrganizationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1OrganizationSlugResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetAnOrganizationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAnOrganizationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAnOrganizationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetOrganizationEntitlementsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ListEntitlementsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetOrganizationEntitlementsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetOrganizationEntitlementsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetOrganizationEntitlementsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListOrganizationMembersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]V1OrganizationMemberResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListOrganizationMembersResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListOrganizationMembersResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListOrganizationMembersResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetOrganizationProjectClaimResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrganizationProjectClaimResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetOrganizationProjectClaimResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetOrganizationProjectClaimResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetOrganizationProjectClaimResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ClaimProjectForOrganizationResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ClaimProjectForOrganizationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ClaimProjectForOrganizationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ClaimProjectForOrganizationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAllProjectsForOrganizationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrganizationProjectsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetAllProjectsForOrganizationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAllProjectsForOrganizationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAllProjectsForOrganizationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProfileResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProfileResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProfileResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProfileResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProfileResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllProjectsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]V1ProjectWithDatabaseResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllProjectsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllProjectsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllProjectsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateAProjectResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *V1ProjectResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAvailableRegionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *RegionsInfoV1Output -} - -// Status returns HTTPResponse.Status -func (r V1GetAvailableRegionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAvailableRegionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAvailableRegionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteAProjectResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProjectRefResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProjectWithDatabaseResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateAProjectResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProjectRefResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListActionRunsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListActionRunResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListActionRunsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListActionRunsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListActionRunsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CountActionRunsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1CountActionRunsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CountActionRunsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CountActionRunsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetActionRunResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ActionRunResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetActionRunResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetActionRunResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetActionRunResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetActionRunLogsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1GetActionRunLogsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetActionRunLogsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetActionRunLogsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateActionRunStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *UpdateRunStatusResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateActionRunStatusResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateActionRunStatusResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateActionRunStatusResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPerformanceAdvisorsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProjectAdvisorsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPerformanceAdvisorsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPerformanceAdvisorsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPerformanceAdvisorsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetSecurityAdvisorsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ProjectAdvisorsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetSecurityAdvisorsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetSecurityAdvisorsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetSecurityAdvisorsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectFunctionCombinedStatsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AnalyticsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectFunctionCombinedStatsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectFunctionCombinedStatsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectFunctionCombinedStatsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectLogsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AnalyticsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectLogsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectLogsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectLogsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectLogsAllResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectLogsAllResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectLogsAllResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectLogsAllResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ScrapeProjectMetricsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ScrapeProjectMetricsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ScrapeProjectMetricsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ScrapeProjectMetricsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectUsageApiCountResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1GetUsageApiCountResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectUsageApiCountResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectUsageApiCountResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectUsageApiCountResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectUsageRequestCountResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1GetUsageApiRequestsCountResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectUsageRequestCountResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectUsageRequestCountResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectUsageRequestCountResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectApiKeysResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]ApiKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectApiKeysResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectApiKeysResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectApiKeysResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateProjectApiKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ApiKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateProjectApiKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateProjectApiKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateProjectApiKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectLegacyApiKeysResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *LegacyApiKeysResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectLegacyApiKeysResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectLegacyApiKeysResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectLegacyApiKeysResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateProjectLegacyApiKeysResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *LegacyApiKeysResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateProjectLegacyApiKeysResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateProjectLegacyApiKeysResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateProjectLegacyApiKeysResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteProjectApiKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ApiKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteProjectApiKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteProjectApiKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteProjectApiKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectApiKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ApiKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectApiKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectApiKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectApiKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateProjectApiKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ApiKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateProjectApiKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateProjectApiKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateProjectApiKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListProjectAddonsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListProjectAddonsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListProjectAddonsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListProjectAddonsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListProjectAddonsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ApplyProjectAddonResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ApplyProjectAddonResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ApplyProjectAddonResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ApplyProjectAddonResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RemoveProjectAddonResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RemoveProjectAddonResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RemoveProjectAddonResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RemoveProjectAddonResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DisablePreviewBranchingResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DisablePreviewBranchingResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DisablePreviewBranchingResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DisablePreviewBranchingResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllBranchesResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]BranchResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllBranchesResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllBranchesResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllBranchesResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *BranchResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetABranchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BranchResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetABranchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetABranchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetABranchResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteProjectClaimTokenResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteProjectClaimTokenResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteProjectClaimTokenResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteProjectClaimTokenResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectClaimTokenResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProjectClaimTokenResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectClaimTokenResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectClaimTokenResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectClaimTokenResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateProjectClaimTokenResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *CreateProjectClaimTokenResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateProjectClaimTokenResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateProjectClaimTokenResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateProjectClaimTokenResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteLoginRolesResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DeleteRolesResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteLoginRolesResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteLoginRolesResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteLoginRolesResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateLoginRoleResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *CreateRoleResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateLoginRoleResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateLoginRoleResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateLoginRoleResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAuthServiceConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AuthConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetAuthServiceConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAuthServiceConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAuthServiceConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateAuthServiceConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AuthConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateAuthServiceConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateAuthServiceConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateAuthServiceConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectSigningKeysResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SigningKeysResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectSigningKeysResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectSigningKeysResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectSigningKeysResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateProjectSigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateProjectSigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateProjectSigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateProjectSigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetLegacySigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetLegacySigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetLegacySigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetLegacySigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateLegacySigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateLegacySigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateLegacySigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateLegacySigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RemoveProjectSigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1RemoveProjectSigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RemoveProjectSigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RemoveProjectSigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectSigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectSigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectSigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectSigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateProjectSigningKeyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SigningKeyResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateProjectSigningKeyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateProjectSigningKeyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateProjectSigningKeyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllSsoProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListProvidersResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllSsoProviderResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllSsoProviderResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllSsoProviderResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateASsoProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *CreateProviderResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateASsoProviderResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateASsoProviderResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateASsoProviderResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteASsoProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DeleteProviderResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteASsoProviderResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteASsoProviderResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteASsoProviderResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetASsoProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *GetProviderResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetASsoProviderResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetASsoProviderResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetASsoProviderResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateASsoProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *UpdateProviderResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateASsoProviderResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateASsoProviderResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateASsoProviderResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListProjectTpaIntegrationsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]ThirdPartyAuthOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListProjectTpaIntegrationsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListProjectTpaIntegrationsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListProjectTpaIntegrationsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateProjectTpaIntegrationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ThirdPartyAuthOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateProjectTpaIntegrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateProjectTpaIntegrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateProjectTpaIntegrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteProjectTpaIntegrationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ThirdPartyAuthOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeleteProjectTpaIntegrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteProjectTpaIntegrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteProjectTpaIntegrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectTpaIntegrationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ThirdPartyAuthOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectTpaIntegrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectTpaIntegrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectTpaIntegrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectPgbouncerConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1PgbouncerConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectPgbouncerConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectPgbouncerConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectPgbouncerConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPoolerConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]SupavisorConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPoolerConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPoolerConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPoolerConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdatePoolerConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *UpdateSupavisorConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdatePoolerConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdatePoolerConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdatePoolerConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPostgresConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PostgresConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPostgresConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPostgresConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPostgresConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdatePostgresConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PostgresConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdatePostgresConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdatePostgresConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdatePostgresConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetDatabaseDiskResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DiskResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetDatabaseDiskResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetDatabaseDiskResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetDatabaseDiskResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ModifyDatabaseDiskResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ModifyDatabaseDiskResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ModifyDatabaseDiskResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ModifyDatabaseDiskResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetProjectDiskAutoscaleConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DiskAutoscaleConfigOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetProjectDiskAutoscaleConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetProjectDiskAutoscaleConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetProjectDiskAutoscaleConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetDiskUtilizationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DiskUtilMetricsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetDiskUtilizationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetDiskUtilizationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetDiskUtilizationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetRealtimeConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *RealtimeConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetRealtimeConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetRealtimeConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetRealtimeConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateRealtimeConfigResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1UpdateRealtimeConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateRealtimeConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateRealtimeConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ShutdownRealtimeResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ShutdownRealtimeResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ShutdownRealtimeResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ShutdownRealtimeResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetStorageConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *StorageConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetStorageConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetStorageConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetStorageConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateStorageConfigResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1UpdateStorageConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateStorageConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateStorageConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteHostnameConfigResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteHostnameConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteHostnameConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteHostnameConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetHostnameConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *UpdateCustomHostnameResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetHostnameConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetHostnameConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetHostnameConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ActivateCustomHostnameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *UpdateCustomHostnameResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ActivateCustomHostnameResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ActivateCustomHostnameResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ActivateCustomHostnameResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateHostnameConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *UpdateCustomHostnameResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateHostnameConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateHostnameConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateHostnameConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1VerifyDnsConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *UpdateCustomHostnameResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1VerifyDnsConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1VerifyDnsConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1VerifyDnsConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllBackupsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1BackupsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllBackupsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllBackupsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllBackupsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RestorePhysicalBackupResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RestorePhysicalBackupResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RestorePhysicalBackupResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RestorePhysicalBackupResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RestorePitrBackupResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RestorePitrBackupResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RestorePitrBackupResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RestorePitrBackupResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetRestorePointResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1RestorePointResponse -} - -// Status returns HTTPResponse.Status -func (r V1GetRestorePointResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetRestorePointResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetRestorePointResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateRestorePointResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *V1RestorePointResponse -} - -// Status returns HTTPResponse.Status -func (r V1CreateRestorePointResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateRestorePointResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateRestorePointResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetBackupScheduleResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1BackupScheduleResponseOutput - JSON402 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1GetBackupScheduleResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetBackupScheduleResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetBackupScheduleResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateBackupScheduleResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1BackupScheduleResponseOutput - JSON402 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1UpdateBackupScheduleResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateBackupScheduleResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateBackupScheduleResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UndoResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1UndoResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UndoResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UndoResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetDatabaseMetadataResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *GetProjectDbMetadataResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetDatabaseMetadataResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetDatabaseMetadataResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetDatabaseMetadataResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *JitAccessResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1AuthorizeJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *JitAuthorizeAccessResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1AuthorizeJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1AuthorizeJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1AuthorizeJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *JitAccessResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1InviteExternalJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *InviteExternalUserJitResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1InviteExternalJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1InviteExternalJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1InviteExternalJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1AcceptInviteExternalJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *JitAccessResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1AcceptInviteExternalJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1AcceptInviteExternalJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1AcceptInviteExternalJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteInviteExternalJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteInviteExternalJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteInviteExternalJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteInviteExternalJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *JitListAccessResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteJitAccessResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteJitAccessResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteJitAccessResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteJitAccessResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RollbackMigrationsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RollbackMigrationsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RollbackMigrationsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RollbackMigrationsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListMigrationHistoryResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1ListMigrationsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListMigrationHistoryResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListMigrationHistoryResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListMigrationHistoryResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ApplyAMigrationResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ApplyAMigrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ApplyAMigrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ApplyAMigrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpsertAMigrationResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1UpsertAMigrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpsertAMigrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpsertAMigrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAMigrationResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1GetMigrationResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetAMigrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAMigrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAMigrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1PatchAMigrationResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1PatchAMigrationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1PatchAMigrationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1PatchAMigrationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetDatabaseOpenapiResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *map[string]interface{} -} - -// Status returns HTTPResponse.Status -func (r V1GetDatabaseOpenapiResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetDatabaseOpenapiResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetDatabaseOpenapiResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateDatabasePasswordResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1UpdatePasswordResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateDatabasePasswordResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateDatabasePasswordResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateDatabasePasswordResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RunAQueryResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RunAQueryResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RunAQueryResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RunAQueryResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ReadOnlyQueryResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1ReadOnlyQueryResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ReadOnlyQueryResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ReadOnlyQueryResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1EnableDatabaseWebhookResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1EnableDatabaseWebhookResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1EnableDatabaseWebhookResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1EnableDatabaseWebhookResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllFunctionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]FunctionResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllFunctionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllFunctionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllFunctionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CreateAFunctionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *FunctionResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1CreateAFunctionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CreateAFunctionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CreateAFunctionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1BulkUpdateFunctionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BulkUpdateFunctionResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1BulkUpdateFunctionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1BulkUpdateFunctionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1BulkUpdateFunctionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeployAFunctionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *DeployFunctionResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1DeployAFunctionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeployAFunctionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeployAFunctionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteAFunctionResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteAFunctionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteAFunctionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteAFunctionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAFunctionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FunctionSlugResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetAFunctionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAFunctionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAFunctionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateAFunctionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FunctionSlugResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateAFunctionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateAFunctionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateAFunctionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetAFunctionBodyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *StreamableFile -} - -// Status returns HTTPResponse.Status -func (r V1GetAFunctionBodyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetAFunctionBodyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetAFunctionBodyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetServicesHealthResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]V1ServiceHealthResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetServicesHealthResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetServicesHealthResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetServicesHealthResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetJitAccessConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1GetJitAccessConfig200JSONResponseBody -} - -// Status returns HTTPResponse.Status -func (r V1GetJitAccessConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetJitAccessConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetJitAccessConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateJitAccessConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1UpdateJitAccessConfig200JSONResponseBody -} - -// Status returns HTTPResponse.Status -func (r V1UpdateJitAccessConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateJitAccessConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateJitAccessConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeleteNetworkBansResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeleteNetworkBansResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeleteNetworkBansResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeleteNetworkBansResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllNetworkBansResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *NetworkBanResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllNetworkBansResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllNetworkBansResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllNetworkBansResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllNetworkBansEnrichedResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *NetworkBanResponseEnrichedOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllNetworkBansEnrichedResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllNetworkBansEnrichedResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllNetworkBansEnrichedResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetNetworkRestrictionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *NetworkRestrictionsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetNetworkRestrictionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetNetworkRestrictionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetNetworkRestrictionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1PatchNetworkRestrictionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *NetworkRestrictionsV2ResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1PatchNetworkRestrictionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1PatchNetworkRestrictionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1PatchNetworkRestrictionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateNetworkRestrictionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *NetworkRestrictionsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateNetworkRestrictionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateNetworkRestrictionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateNetworkRestrictionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1PauseAProjectResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1PauseAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1PauseAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1PauseAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPgsodiumConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PgsodiumConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPgsodiumConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPgsodiumConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPgsodiumConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdatePgsodiumConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PgsodiumConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdatePgsodiumConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdatePgsodiumConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdatePgsodiumConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPostgrestServiceConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PostgrestConfigWithJWTSecretResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPostgrestServiceConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPostgrestServiceConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPostgrestServiceConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdatePostgrestServiceConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *V1PostgrestConfigResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdatePostgrestServiceConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdatePostgrestServiceConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdatePostgrestServiceConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RemoveAReadReplicaResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RemoveAReadReplicaResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RemoveAReadReplicaResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RemoveAReadReplicaResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1SetupAReadReplicaResponse struct { - Body []byte - HTTPResponse *http.Response - JSON402 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1SetupAReadReplicaResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1SetupAReadReplicaResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1SetupAReadReplicaResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetReadonlyModeStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ReadOnlyStatusResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetReadonlyModeStatusResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetReadonlyModeStatusResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetReadonlyModeStatusResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DisableReadonlyModeTemporarilyResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DisableReadonlyModeTemporarilyResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DisableReadonlyModeTemporarilyResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DisableReadonlyModeTemporarilyResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RestartAProjectResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RestartAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RestartAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RestartAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAvailableRestoreVersionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *GetProjectAvailableRestoreVersionsResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAvailableRestoreVersionsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAvailableRestoreVersionsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAvailableRestoreVersionsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1RestoreAProjectResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1RestoreAProjectResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1RestoreAProjectResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1RestoreAProjectResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CancelAProjectRestorationResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1CancelAProjectRestorationResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CancelAProjectRestorationResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CancelAProjectRestorationResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1BulkDeleteSecretsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1BulkDeleteSecretsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1BulkDeleteSecretsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1BulkDeleteSecretsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllSecretsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]SecretResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllSecretsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllSecretsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllSecretsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1BulkCreateSecretsResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1BulkCreateSecretsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1BulkCreateSecretsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1BulkCreateSecretsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetSslEnforcementConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SslEnforcementResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetSslEnforcementConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetSslEnforcementConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetSslEnforcementConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpdateSslEnforcementConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SslEnforcementResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpdateSslEnforcementConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpdateSslEnforcementConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpdateSslEnforcementConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllBucketsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *[]V1StorageBucketResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllBucketsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllBucketsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllBucketsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GenerateTypescriptTypesResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *TypescriptResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GenerateTypescriptTypesResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GenerateTypescriptTypesResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GenerateTypescriptTypesResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1UpgradePostgresVersionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ProjectUpgradeInitiateResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1UpgradePostgresVersionResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1UpgradePostgresVersionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1UpgradePostgresVersionResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPostgresUpgradeEligibilityResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProjectUpgradeEligibilityResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPostgresUpgradeEligibilityResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPostgresUpgradeEligibilityResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPostgresUpgradeEligibilityResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetPostgresUpgradeStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DatabaseUpgradeStatusResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetPostgresUpgradeStatusResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetPostgresUpgradeStatusResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetPostgresUpgradeStatusResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1DeactivateVanitySubdomainConfigResponse struct { - Body []byte - HTTPResponse *http.Response -} - -// Status returns HTTPResponse.Status -func (r V1DeactivateVanitySubdomainConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1DeactivateVanitySubdomainConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1DeactivateVanitySubdomainConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetVanitySubdomainConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *VanitySubdomainConfigResponseOutput - JSON400 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1GetVanitySubdomainConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetVanitySubdomainConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetVanitySubdomainConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ActivateVanitySubdomainConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ActivateVanitySubdomainResponseOutput - JSON400 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1ActivateVanitySubdomainConfigResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ActivateVanitySubdomainConfigResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ActivateVanitySubdomainConfigResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1CheckVanitySubdomainAvailabilityResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *SubdomainAvailabilityResponseOutput - JSON400 *PlanGateErrorBody -} - -// Status returns HTTPResponse.Status -func (r V1CheckVanitySubdomainAvailabilityResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1CheckVanitySubdomainAvailabilityResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1CheckVanitySubdomainAvailabilityResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1ListAllSnippetsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SnippetListOutput -} - -// Status returns HTTPResponse.Status -func (r V1ListAllSnippetsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1ListAllSnippetsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1ListAllSnippetsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type V1GetASnippetResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SnippetResponseOutput -} - -// Status returns HTTPResponse.Status -func (r V1GetASnippetResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r V1GetASnippetResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r V1GetASnippetResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -// V1DeleteABranchWithResponse request returning *V1DeleteABranchResponse -func (c *ClientWithResponses) V1DeleteABranchWithResponse(ctx context.Context, branchIdOrRef string, params *V1DeleteABranchParams, reqEditors ...RequestEditorFn) (*V1DeleteABranchResponse, error) { - rsp, err := c.V1DeleteABranch(ctx, branchIdOrRef, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteABranchResponse(rsp) -} - -// V1GetABranchConfigWithResponse request returning *V1GetABranchConfigResponse -func (c *ClientWithResponses) V1GetABranchConfigWithResponse(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*V1GetABranchConfigResponse, error) { - rsp, err := c.V1GetABranchConfig(ctx, branchIdOrRef, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetABranchConfigResponse(rsp) -} - -// V1UpdateABranchConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateABranchConfigResponse -func (c *ClientWithResponses) V1UpdateABranchConfigWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateABranchConfigResponse, error) { - rsp, err := c.V1UpdateABranchConfigWithBody(ctx, branchIdOrRef, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateABranchConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateABranchConfigWithResponse(ctx context.Context, branchIdOrRef string, body V1UpdateABranchConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateABranchConfigResponse, error) { - rsp, err := c.V1UpdateABranchConfig(ctx, branchIdOrRef, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateABranchConfigResponse(rsp) -} - -// V1DiffABranchWithResponse request returning *V1DiffABranchResponse -func (c *ClientWithResponses) V1DiffABranchWithResponse(ctx context.Context, branchIdOrRef string, params *V1DiffABranchParams, reqEditors ...RequestEditorFn) (*V1DiffABranchResponse, error) { - rsp, err := c.V1DiffABranch(ctx, branchIdOrRef, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DiffABranchResponse(rsp) -} - -// V1MergeABranchWithBodyWithResponse request with arbitrary body returning *V1MergeABranchResponse -func (c *ClientWithResponses) V1MergeABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1MergeABranchResponse, error) { - rsp, err := c.V1MergeABranchWithBody(ctx, branchIdOrRef, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1MergeABranchResponse(rsp) -} - -func (c *ClientWithResponses) V1MergeABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1MergeABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1MergeABranchResponse, error) { - rsp, err := c.V1MergeABranch(ctx, branchIdOrRef, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1MergeABranchResponse(rsp) -} - -// V1PushABranchWithBodyWithResponse request with arbitrary body returning *V1PushABranchResponse -func (c *ClientWithResponses) V1PushABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PushABranchResponse, error) { - rsp, err := c.V1PushABranchWithBody(ctx, branchIdOrRef, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PushABranchResponse(rsp) -} - -func (c *ClientWithResponses) V1PushABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1PushABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PushABranchResponse, error) { - rsp, err := c.V1PushABranch(ctx, branchIdOrRef, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PushABranchResponse(rsp) -} - -// V1ResetABranchWithBodyWithResponse request with arbitrary body returning *V1ResetABranchResponse -func (c *ClientWithResponses) V1ResetABranchWithBodyWithResponse(ctx context.Context, branchIdOrRef string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ResetABranchResponse, error) { - rsp, err := c.V1ResetABranchWithBody(ctx, branchIdOrRef, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ResetABranchResponse(rsp) -} - -func (c *ClientWithResponses) V1ResetABranchWithResponse(ctx context.Context, branchIdOrRef string, body V1ResetABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ResetABranchResponse, error) { - rsp, err := c.V1ResetABranch(ctx, branchIdOrRef, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ResetABranchResponse(rsp) -} - -// V1RestoreABranchWithResponse request returning *V1RestoreABranchResponse -func (c *ClientWithResponses) V1RestoreABranchWithResponse(ctx context.Context, branchIdOrRef string, reqEditors ...RequestEditorFn) (*V1RestoreABranchResponse, error) { - rsp, err := c.V1RestoreABranch(ctx, branchIdOrRef, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestoreABranchResponse(rsp) -} - -// V1AuthorizeUserWithResponse request returning *V1AuthorizeUserResponse -func (c *ClientWithResponses) V1AuthorizeUserWithResponse(ctx context.Context, params *V1AuthorizeUserParams, reqEditors ...RequestEditorFn) (*V1AuthorizeUserResponse, error) { - rsp, err := c.V1AuthorizeUser(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1AuthorizeUserResponse(rsp) -} - -// V1OauthAuthorizeProjectClaimWithResponse request returning *V1OauthAuthorizeProjectClaimResponse -func (c *ClientWithResponses) V1OauthAuthorizeProjectClaimWithResponse(ctx context.Context, params *V1OauthAuthorizeProjectClaimParams, reqEditors ...RequestEditorFn) (*V1OauthAuthorizeProjectClaimResponse, error) { - rsp, err := c.V1OauthAuthorizeProjectClaim(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1OauthAuthorizeProjectClaimResponse(rsp) -} - -// V1RevokeTokenWithBodyWithResponse request with arbitrary body returning *V1RevokeTokenResponse -func (c *ClientWithResponses) V1RevokeTokenWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RevokeTokenResponse, error) { - rsp, err := c.V1RevokeTokenWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RevokeTokenResponse(rsp) -} - -func (c *ClientWithResponses) V1RevokeTokenWithResponse(ctx context.Context, body V1RevokeTokenJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RevokeTokenResponse, error) { - rsp, err := c.V1RevokeToken(ctx, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RevokeTokenResponse(rsp) -} - -// V1ExchangeOauthTokenWithBodyWithResponse request with arbitrary body returning *V1ExchangeOauthTokenResponse -func (c *ClientWithResponses) V1ExchangeOauthTokenWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ExchangeOauthTokenResponse, error) { - rsp, err := c.V1ExchangeOauthTokenWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ExchangeOauthTokenResponse(rsp) -} - -func (c *ClientWithResponses) V1ExchangeOauthTokenWithFormdataBodyWithResponse(ctx context.Context, body V1ExchangeOauthTokenFormdataRequestBody, reqEditors ...RequestEditorFn) (*V1ExchangeOauthTokenResponse, error) { - rsp, err := c.V1ExchangeOauthTokenWithFormdataBody(ctx, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ExchangeOauthTokenResponse(rsp) -} - -// V1ListAllOrganizationsWithResponse request returning *V1ListAllOrganizationsResponse -func (c *ClientWithResponses) V1ListAllOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllOrganizationsResponse, error) { - rsp, err := c.V1ListAllOrganizations(ctx, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllOrganizationsResponse(rsp) -} - -// V1CreateAnOrganizationWithBodyWithResponse request with arbitrary body returning *V1CreateAnOrganizationResponse -func (c *ClientWithResponses) V1CreateAnOrganizationWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAnOrganizationResponse, error) { - rsp, err := c.V1CreateAnOrganizationWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAnOrganizationResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateAnOrganizationWithResponse(ctx context.Context, body V1CreateAnOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAnOrganizationResponse, error) { - rsp, err := c.V1CreateAnOrganization(ctx, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAnOrganizationResponse(rsp) -} - -// V1GetAnOrganizationWithResponse request returning *V1GetAnOrganizationResponse -func (c *ClientWithResponses) V1GetAnOrganizationWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetAnOrganizationResponse, error) { - rsp, err := c.V1GetAnOrganization(ctx, slug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAnOrganizationResponse(rsp) -} - -// V1GetOrganizationEntitlementsWithResponse request returning *V1GetOrganizationEntitlementsResponse -func (c *ClientWithResponses) V1GetOrganizationEntitlementsWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetOrganizationEntitlementsResponse, error) { - rsp, err := c.V1GetOrganizationEntitlements(ctx, slug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetOrganizationEntitlementsResponse(rsp) -} - -// V1ListOrganizationMembersWithResponse request returning *V1ListOrganizationMembersResponse -func (c *ClientWithResponses) V1ListOrganizationMembersWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1ListOrganizationMembersResponse, error) { - rsp, err := c.V1ListOrganizationMembers(ctx, slug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListOrganizationMembersResponse(rsp) -} - -// V1GetOrganizationProjectClaimWithResponse request returning *V1GetOrganizationProjectClaimResponse -func (c *ClientWithResponses) V1GetOrganizationProjectClaimWithResponse(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*V1GetOrganizationProjectClaimResponse, error) { - rsp, err := c.V1GetOrganizationProjectClaim(ctx, slug, token, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetOrganizationProjectClaimResponse(rsp) -} - -// V1ClaimProjectForOrganizationWithResponse request returning *V1ClaimProjectForOrganizationResponse -func (c *ClientWithResponses) V1ClaimProjectForOrganizationWithResponse(ctx context.Context, slug string, token string, reqEditors ...RequestEditorFn) (*V1ClaimProjectForOrganizationResponse, error) { - rsp, err := c.V1ClaimProjectForOrganization(ctx, slug, token, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ClaimProjectForOrganizationResponse(rsp) -} - -// V1GetAllProjectsForOrganizationWithResponse request returning *V1GetAllProjectsForOrganizationResponse -func (c *ClientWithResponses) V1GetAllProjectsForOrganizationWithResponse(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*V1GetAllProjectsForOrganizationResponse, error) { - rsp, err := c.V1GetAllProjectsForOrganization(ctx, slug, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAllProjectsForOrganizationResponse(rsp) -} - -// V1GetProfileWithResponse request returning *V1GetProfileResponse -func (c *ClientWithResponses) V1GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1GetProfileResponse, error) { - rsp, err := c.V1GetProfile(ctx, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProfileResponse(rsp) -} - -// V1ListAllProjectsWithResponse request returning *V1ListAllProjectsResponse -func (c *ClientWithResponses) V1ListAllProjectsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllProjectsResponse, error) { - rsp, err := c.V1ListAllProjects(ctx, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllProjectsResponse(rsp) -} - -// V1CreateAProjectWithBodyWithResponse request with arbitrary body returning *V1CreateAProjectResponse -func (c *ClientWithResponses) V1CreateAProjectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAProjectResponse, error) { - rsp, err := c.V1CreateAProjectWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAProjectResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateAProjectWithResponse(ctx context.Context, body V1CreateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAProjectResponse, error) { - rsp, err := c.V1CreateAProject(ctx, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAProjectResponse(rsp) -} - -// V1GetAvailableRegionsWithResponse request returning *V1GetAvailableRegionsResponse -func (c *ClientWithResponses) V1GetAvailableRegionsWithResponse(ctx context.Context, params *V1GetAvailableRegionsParams, reqEditors ...RequestEditorFn) (*V1GetAvailableRegionsResponse, error) { - rsp, err := c.V1GetAvailableRegions(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAvailableRegionsResponse(rsp) -} - -// V1DeleteAProjectWithResponse request returning *V1DeleteAProjectResponse -func (c *ClientWithResponses) V1DeleteAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteAProjectResponse, error) { - rsp, err := c.V1DeleteAProject(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteAProjectResponse(rsp) -} - -// V1GetProjectWithResponse request returning *V1GetProjectResponse -func (c *ClientWithResponses) V1GetProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectResponse, error) { - rsp, err := c.V1GetProject(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectResponse(rsp) -} - -// V1UpdateAProjectWithBodyWithResponse request with arbitrary body returning *V1UpdateAProjectResponse -func (c *ClientWithResponses) V1UpdateAProjectWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAProjectResponse, error) { - rsp, err := c.V1UpdateAProjectWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAProjectResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateAProjectWithResponse(ctx context.Context, ref string, body V1UpdateAProjectJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAProjectResponse, error) { - rsp, err := c.V1UpdateAProject(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAProjectResponse(rsp) -} - -// V1ListActionRunsWithResponse request returning *V1ListActionRunsResponse -func (c *ClientWithResponses) V1ListActionRunsWithResponse(ctx context.Context, ref string, params *V1ListActionRunsParams, reqEditors ...RequestEditorFn) (*V1ListActionRunsResponse, error) { - rsp, err := c.V1ListActionRuns(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListActionRunsResponse(rsp) -} - -// V1CountActionRunsWithResponse request returning *V1CountActionRunsResponse -func (c *ClientWithResponses) V1CountActionRunsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CountActionRunsResponse, error) { - rsp, err := c.V1CountActionRuns(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CountActionRunsResponse(rsp) -} - -// V1GetActionRunWithResponse request returning *V1GetActionRunResponse -func (c *ClientWithResponses) V1GetActionRunWithResponse(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*V1GetActionRunResponse, error) { - rsp, err := c.V1GetActionRun(ctx, ref, runId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetActionRunResponse(rsp) -} - -// V1GetActionRunLogsWithResponse request returning *V1GetActionRunLogsResponse -func (c *ClientWithResponses) V1GetActionRunLogsWithResponse(ctx context.Context, ref string, runId string, reqEditors ...RequestEditorFn) (*V1GetActionRunLogsResponse, error) { - rsp, err := c.V1GetActionRunLogs(ctx, ref, runId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetActionRunLogsResponse(rsp) -} - -// V1UpdateActionRunStatusWithBodyWithResponse request with arbitrary body returning *V1UpdateActionRunStatusResponse -func (c *ClientWithResponses) V1UpdateActionRunStatusWithBodyWithResponse(ctx context.Context, ref string, runId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateActionRunStatusResponse, error) { - rsp, err := c.V1UpdateActionRunStatusWithBody(ctx, ref, runId, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateActionRunStatusResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateActionRunStatusWithResponse(ctx context.Context, ref string, runId string, body V1UpdateActionRunStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateActionRunStatusResponse, error) { - rsp, err := c.V1UpdateActionRunStatus(ctx, ref, runId, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateActionRunStatusResponse(rsp) -} - -// V1GetPerformanceAdvisorsWithResponse request returning *V1GetPerformanceAdvisorsResponse -func (c *ClientWithResponses) V1GetPerformanceAdvisorsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPerformanceAdvisorsResponse, error) { - rsp, err := c.V1GetPerformanceAdvisors(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPerformanceAdvisorsResponse(rsp) -} - -// V1GetSecurityAdvisorsWithResponse request returning *V1GetSecurityAdvisorsResponse -func (c *ClientWithResponses) V1GetSecurityAdvisorsWithResponse(ctx context.Context, ref string, params *V1GetSecurityAdvisorsParams, reqEditors ...RequestEditorFn) (*V1GetSecurityAdvisorsResponse, error) { - rsp, err := c.V1GetSecurityAdvisors(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetSecurityAdvisorsResponse(rsp) -} - -// V1GetProjectFunctionCombinedStatsWithResponse request returning *V1GetProjectFunctionCombinedStatsResponse -func (c *ClientWithResponses) V1GetProjectFunctionCombinedStatsWithResponse(ctx context.Context, ref string, params *V1GetProjectFunctionCombinedStatsParams, reqEditors ...RequestEditorFn) (*V1GetProjectFunctionCombinedStatsResponse, error) { - rsp, err := c.V1GetProjectFunctionCombinedStats(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectFunctionCombinedStatsResponse(rsp) -} - -// V1GetProjectLogsWithResponse request returning *V1GetProjectLogsResponse -func (c *ClientWithResponses) V1GetProjectLogsWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*V1GetProjectLogsResponse, error) { - rsp, err := c.V1GetProjectLogs(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectLogsResponse(rsp) -} - -// V1GetProjectLogsAllWithResponse request returning *V1GetProjectLogsAllResponse -func (c *ClientWithResponses) V1GetProjectLogsAllWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectLogsAllResponse, error) { - rsp, err := c.V1GetProjectLogsAll(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectLogsAllResponse(rsp) -} - -// V1ScrapeProjectMetricsWithResponse request returning *V1ScrapeProjectMetricsResponse -func (c *ClientWithResponses) V1ScrapeProjectMetricsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ScrapeProjectMetricsResponse, error) { - rsp, err := c.V1ScrapeProjectMetrics(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ScrapeProjectMetricsResponse(rsp) -} - -// V1GetProjectUsageApiCountWithResponse request returning *V1GetProjectUsageApiCountResponse -func (c *ClientWithResponses) V1GetProjectUsageApiCountWithResponse(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*V1GetProjectUsageApiCountResponse, error) { - rsp, err := c.V1GetProjectUsageApiCount(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectUsageApiCountResponse(rsp) -} - -// V1GetProjectUsageRequestCountWithResponse request returning *V1GetProjectUsageRequestCountResponse -func (c *ClientWithResponses) V1GetProjectUsageRequestCountWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectUsageRequestCountResponse, error) { - rsp, err := c.V1GetProjectUsageRequestCount(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectUsageRequestCountResponse(rsp) -} - -// V1GetProjectApiKeysWithResponse request returning *V1GetProjectApiKeysResponse -func (c *ClientWithResponses) V1GetProjectApiKeysWithResponse(ctx context.Context, ref string, params *V1GetProjectApiKeysParams, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeysResponse, error) { - rsp, err := c.V1GetProjectApiKeys(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectApiKeysResponse(rsp) -} - -// V1CreateProjectApiKeyWithBodyWithResponse request with arbitrary body returning *V1CreateProjectApiKeyResponse -func (c *ClientWithResponses) V1CreateProjectApiKeyWithBodyWithResponse(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectApiKeyResponse, error) { - rsp, err := c.V1CreateProjectApiKeyWithBody(ctx, ref, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectApiKeyResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateProjectApiKeyWithResponse(ctx context.Context, ref string, params *V1CreateProjectApiKeyParams, body V1CreateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectApiKeyResponse, error) { - rsp, err := c.V1CreateProjectApiKey(ctx, ref, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectApiKeyResponse(rsp) -} - -// V1GetProjectLegacyApiKeysWithResponse request returning *V1GetProjectLegacyApiKeysResponse -func (c *ClientWithResponses) V1GetProjectLegacyApiKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectLegacyApiKeysResponse, error) { - rsp, err := c.V1GetProjectLegacyApiKeys(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectLegacyApiKeysResponse(rsp) -} - -// V1UpdateProjectLegacyApiKeysWithResponse request returning *V1UpdateProjectLegacyApiKeysResponse -func (c *ClientWithResponses) V1UpdateProjectLegacyApiKeysWithResponse(ctx context.Context, ref string, params *V1UpdateProjectLegacyApiKeysParams, reqEditors ...RequestEditorFn) (*V1UpdateProjectLegacyApiKeysResponse, error) { - rsp, err := c.V1UpdateProjectLegacyApiKeys(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateProjectLegacyApiKeysResponse(rsp) -} - -// V1DeleteProjectApiKeyWithResponse request returning *V1DeleteProjectApiKeyResponse -func (c *ClientWithResponses) V1DeleteProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1DeleteProjectApiKeyParams, reqEditors ...RequestEditorFn) (*V1DeleteProjectApiKeyResponse, error) { - rsp, err := c.V1DeleteProjectApiKey(ctx, ref, id, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteProjectApiKeyResponse(rsp) -} - -// V1GetProjectApiKeyWithResponse request returning *V1GetProjectApiKeyResponse -func (c *ClientWithResponses) V1GetProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1GetProjectApiKeyParams, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeyResponse, error) { - rsp, err := c.V1GetProjectApiKey(ctx, ref, id, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectApiKeyResponse(rsp) -} - -// V1UpdateProjectApiKeyWithBodyWithResponse request with arbitrary body returning *V1UpdateProjectApiKeyResponse -func (c *ClientWithResponses) V1UpdateProjectApiKeyWithBodyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateProjectApiKeyResponse, error) { - rsp, err := c.V1UpdateProjectApiKeyWithBody(ctx, ref, id, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateProjectApiKeyResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateProjectApiKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, params *V1UpdateProjectApiKeyParams, body V1UpdateProjectApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateProjectApiKeyResponse, error) { - rsp, err := c.V1UpdateProjectApiKey(ctx, ref, id, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateProjectApiKeyResponse(rsp) -} - -// V1ListProjectAddonsWithResponse request returning *V1ListProjectAddonsResponse -func (c *ClientWithResponses) V1ListProjectAddonsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListProjectAddonsResponse, error) { - rsp, err := c.V1ListProjectAddons(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListProjectAddonsResponse(rsp) -} - -// V1ApplyProjectAddonWithBodyWithResponse request with arbitrary body returning *V1ApplyProjectAddonResponse -func (c *ClientWithResponses) V1ApplyProjectAddonWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ApplyProjectAddonResponse, error) { - rsp, err := c.V1ApplyProjectAddonWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ApplyProjectAddonResponse(rsp) -} - -func (c *ClientWithResponses) V1ApplyProjectAddonWithResponse(ctx context.Context, ref string, body V1ApplyProjectAddonJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ApplyProjectAddonResponse, error) { - rsp, err := c.V1ApplyProjectAddon(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ApplyProjectAddonResponse(rsp) -} - -// V1RemoveProjectAddonWithResponse request returning *V1RemoveProjectAddonResponse -func (c *ClientWithResponses) V1RemoveProjectAddonWithResponse(ctx context.Context, ref string, addonVariant struct { - union json.RawMessage -}, reqEditors ...RequestEditorFn) (*V1RemoveProjectAddonResponse, error) { - rsp, err := c.V1RemoveProjectAddon(ctx, ref, addonVariant, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RemoveProjectAddonResponse(rsp) -} - -// V1DisablePreviewBranchingWithResponse request returning *V1DisablePreviewBranchingResponse -func (c *ClientWithResponses) V1DisablePreviewBranchingWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DisablePreviewBranchingResponse, error) { - rsp, err := c.V1DisablePreviewBranching(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DisablePreviewBranchingResponse(rsp) -} - -// V1ListAllBranchesWithResponse request returning *V1ListAllBranchesResponse -func (c *ClientWithResponses) V1ListAllBranchesWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBranchesResponse, error) { - rsp, err := c.V1ListAllBranches(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllBranchesResponse(rsp) -} - -// V1CreateABranchWithBodyWithResponse request with arbitrary body returning *V1CreateABranchResponse -func (c *ClientWithResponses) V1CreateABranchWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateABranchResponse, error) { - rsp, err := c.V1CreateABranchWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateABranchResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateABranchWithResponse(ctx context.Context, ref string, body V1CreateABranchJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateABranchResponse, error) { - rsp, err := c.V1CreateABranch(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateABranchResponse(rsp) -} - -// V1GetABranchWithResponse request returning *V1GetABranchResponse -func (c *ClientWithResponses) V1GetABranchWithResponse(ctx context.Context, ref string, name string, reqEditors ...RequestEditorFn) (*V1GetABranchResponse, error) { - rsp, err := c.V1GetABranch(ctx, ref, name, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetABranchResponse(rsp) -} - -// V1DeleteProjectClaimTokenWithResponse request returning *V1DeleteProjectClaimTokenResponse -func (c *ClientWithResponses) V1DeleteProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteProjectClaimTokenResponse, error) { - rsp, err := c.V1DeleteProjectClaimToken(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteProjectClaimTokenResponse(rsp) -} - -// V1GetProjectClaimTokenWithResponse request returning *V1GetProjectClaimTokenResponse -func (c *ClientWithResponses) V1GetProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectClaimTokenResponse, error) { - rsp, err := c.V1GetProjectClaimToken(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectClaimTokenResponse(rsp) -} - -// V1CreateProjectClaimTokenWithResponse request returning *V1CreateProjectClaimTokenResponse -func (c *ClientWithResponses) V1CreateProjectClaimTokenWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CreateProjectClaimTokenResponse, error) { - rsp, err := c.V1CreateProjectClaimToken(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectClaimTokenResponse(rsp) -} - -// V1DeleteLoginRolesWithResponse request returning *V1DeleteLoginRolesResponse -func (c *ClientWithResponses) V1DeleteLoginRolesWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteLoginRolesResponse, error) { - rsp, err := c.V1DeleteLoginRoles(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteLoginRolesResponse(rsp) -} - -// V1CreateLoginRoleWithBodyWithResponse request with arbitrary body returning *V1CreateLoginRoleResponse -func (c *ClientWithResponses) V1CreateLoginRoleWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateLoginRoleResponse, error) { - rsp, err := c.V1CreateLoginRoleWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateLoginRoleResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateLoginRoleWithResponse(ctx context.Context, ref string, body V1CreateLoginRoleJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateLoginRoleResponse, error) { - rsp, err := c.V1CreateLoginRole(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateLoginRoleResponse(rsp) -} - -// V1GetAuthServiceConfigWithResponse request returning *V1GetAuthServiceConfigResponse -func (c *ClientWithResponses) V1GetAuthServiceConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetAuthServiceConfigResponse, error) { - rsp, err := c.V1GetAuthServiceConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAuthServiceConfigResponse(rsp) -} - -// V1UpdateAuthServiceConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateAuthServiceConfigResponse -func (c *ClientWithResponses) V1UpdateAuthServiceConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAuthServiceConfigResponse, error) { - rsp, err := c.V1UpdateAuthServiceConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAuthServiceConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateAuthServiceConfigWithResponse(ctx context.Context, ref string, body V1UpdateAuthServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAuthServiceConfigResponse, error) { - rsp, err := c.V1UpdateAuthServiceConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAuthServiceConfigResponse(rsp) -} - -// V1GetProjectSigningKeysWithResponse request returning *V1GetProjectSigningKeysResponse -func (c *ClientWithResponses) V1GetProjectSigningKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectSigningKeysResponse, error) { - rsp, err := c.V1GetProjectSigningKeys(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectSigningKeysResponse(rsp) -} - -// V1CreateProjectSigningKeyWithBodyWithResponse request with arbitrary body returning *V1CreateProjectSigningKeyResponse -func (c *ClientWithResponses) V1CreateProjectSigningKeyWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectSigningKeyResponse, error) { - rsp, err := c.V1CreateProjectSigningKeyWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectSigningKeyResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateProjectSigningKeyWithResponse(ctx context.Context, ref string, body V1CreateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectSigningKeyResponse, error) { - rsp, err := c.V1CreateProjectSigningKey(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectSigningKeyResponse(rsp) -} - -// V1GetLegacySigningKeyWithResponse request returning *V1GetLegacySigningKeyResponse -func (c *ClientWithResponses) V1GetLegacySigningKeyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetLegacySigningKeyResponse, error) { - rsp, err := c.V1GetLegacySigningKey(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetLegacySigningKeyResponse(rsp) -} - -// V1CreateLegacySigningKeyWithResponse request returning *V1CreateLegacySigningKeyResponse -func (c *ClientWithResponses) V1CreateLegacySigningKeyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CreateLegacySigningKeyResponse, error) { - rsp, err := c.V1CreateLegacySigningKey(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateLegacySigningKeyResponse(rsp) -} - -// V1RemoveProjectSigningKeyWithResponse request returning *V1RemoveProjectSigningKeyResponse -func (c *ClientWithResponses) V1RemoveProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1RemoveProjectSigningKeyResponse, error) { - rsp, err := c.V1RemoveProjectSigningKey(ctx, ref, id, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RemoveProjectSigningKeyResponse(rsp) -} - -// V1GetProjectSigningKeyWithResponse request returning *V1GetProjectSigningKeyResponse -func (c *ClientWithResponses) V1GetProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetProjectSigningKeyResponse, error) { - rsp, err := c.V1GetProjectSigningKey(ctx, ref, id, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectSigningKeyResponse(rsp) -} - -// V1UpdateProjectSigningKeyWithBodyWithResponse request with arbitrary body returning *V1UpdateProjectSigningKeyResponse -func (c *ClientWithResponses) V1UpdateProjectSigningKeyWithBodyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateProjectSigningKeyResponse, error) { - rsp, err := c.V1UpdateProjectSigningKeyWithBody(ctx, ref, id, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateProjectSigningKeyResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateProjectSigningKeyWithResponse(ctx context.Context, ref string, id openapi_types.UUID, body V1UpdateProjectSigningKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateProjectSigningKeyResponse, error) { - rsp, err := c.V1UpdateProjectSigningKey(ctx, ref, id, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateProjectSigningKeyResponse(rsp) -} - -// V1ListAllSsoProviderWithResponse request returning *V1ListAllSsoProviderResponse -func (c *ClientWithResponses) V1ListAllSsoProviderWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllSsoProviderResponse, error) { - rsp, err := c.V1ListAllSsoProvider(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllSsoProviderResponse(rsp) -} - -// V1CreateASsoProviderWithBodyWithResponse request with arbitrary body returning *V1CreateASsoProviderResponse -func (c *ClientWithResponses) V1CreateASsoProviderWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateASsoProviderResponse, error) { - rsp, err := c.V1CreateASsoProviderWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateASsoProviderResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateASsoProviderWithResponse(ctx context.Context, ref string, body V1CreateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateASsoProviderResponse, error) { - rsp, err := c.V1CreateASsoProvider(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateASsoProviderResponse(rsp) -} - -// V1DeleteASsoProviderWithResponse request returning *V1DeleteASsoProviderResponse -func (c *ClientWithResponses) V1DeleteASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteASsoProviderResponse, error) { - rsp, err := c.V1DeleteASsoProvider(ctx, ref, providerId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteASsoProviderResponse(rsp) -} - -// V1GetASsoProviderWithResponse request returning *V1GetASsoProviderResponse -func (c *ClientWithResponses) V1GetASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetASsoProviderResponse, error) { - rsp, err := c.V1GetASsoProvider(ctx, ref, providerId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetASsoProviderResponse(rsp) -} - -// V1UpdateASsoProviderWithBodyWithResponse request with arbitrary body returning *V1UpdateASsoProviderResponse -func (c *ClientWithResponses) V1UpdateASsoProviderWithBodyWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateASsoProviderResponse, error) { - rsp, err := c.V1UpdateASsoProviderWithBody(ctx, ref, providerId, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateASsoProviderResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateASsoProviderWithResponse(ctx context.Context, ref string, providerId openapi_types.UUID, body V1UpdateASsoProviderJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateASsoProviderResponse, error) { - rsp, err := c.V1UpdateASsoProvider(ctx, ref, providerId, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateASsoProviderResponse(rsp) -} - -// V1ListProjectTpaIntegrationsWithResponse request returning *V1ListProjectTpaIntegrationsResponse -func (c *ClientWithResponses) V1ListProjectTpaIntegrationsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListProjectTpaIntegrationsResponse, error) { - rsp, err := c.V1ListProjectTpaIntegrations(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListProjectTpaIntegrationsResponse(rsp) -} - -// V1CreateProjectTpaIntegrationWithBodyWithResponse request with arbitrary body returning *V1CreateProjectTpaIntegrationResponse -func (c *ClientWithResponses) V1CreateProjectTpaIntegrationWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateProjectTpaIntegrationResponse, error) { - rsp, err := c.V1CreateProjectTpaIntegrationWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectTpaIntegrationResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateProjectTpaIntegrationWithResponse(ctx context.Context, ref string, body V1CreateProjectTpaIntegrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateProjectTpaIntegrationResponse, error) { - rsp, err := c.V1CreateProjectTpaIntegration(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateProjectTpaIntegrationResponse(rsp) -} - -// V1DeleteProjectTpaIntegrationWithResponse request returning *V1DeleteProjectTpaIntegrationResponse -func (c *ClientWithResponses) V1DeleteProjectTpaIntegrationWithResponse(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteProjectTpaIntegrationResponse, error) { - rsp, err := c.V1DeleteProjectTpaIntegration(ctx, ref, tpaId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteProjectTpaIntegrationResponse(rsp) -} - -// V1GetProjectTpaIntegrationWithResponse request returning *V1GetProjectTpaIntegrationResponse -func (c *ClientWithResponses) V1GetProjectTpaIntegrationWithResponse(ctx context.Context, ref string, tpaId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetProjectTpaIntegrationResponse, error) { - rsp, err := c.V1GetProjectTpaIntegration(ctx, ref, tpaId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectTpaIntegrationResponse(rsp) -} - -// V1GetProjectPgbouncerConfigWithResponse request returning *V1GetProjectPgbouncerConfigResponse -func (c *ClientWithResponses) V1GetProjectPgbouncerConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectPgbouncerConfigResponse, error) { - rsp, err := c.V1GetProjectPgbouncerConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectPgbouncerConfigResponse(rsp) -} - -// V1GetPoolerConfigWithResponse request returning *V1GetPoolerConfigResponse -func (c *ClientWithResponses) V1GetPoolerConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPoolerConfigResponse, error) { - rsp, err := c.V1GetPoolerConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPoolerConfigResponse(rsp) -} - -// V1UpdatePoolerConfigWithBodyWithResponse request with arbitrary body returning *V1UpdatePoolerConfigResponse -func (c *ClientWithResponses) V1UpdatePoolerConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePoolerConfigResponse, error) { - rsp, err := c.V1UpdatePoolerConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePoolerConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdatePoolerConfigWithResponse(ctx context.Context, ref string, body V1UpdatePoolerConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePoolerConfigResponse, error) { - rsp, err := c.V1UpdatePoolerConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePoolerConfigResponse(rsp) -} - -// V1GetPostgresConfigWithResponse request returning *V1GetPostgresConfigResponse -func (c *ClientWithResponses) V1GetPostgresConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgresConfigResponse, error) { - rsp, err := c.V1GetPostgresConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPostgresConfigResponse(rsp) -} - -// V1UpdatePostgresConfigWithBodyWithResponse request with arbitrary body returning *V1UpdatePostgresConfigResponse -func (c *ClientWithResponses) V1UpdatePostgresConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePostgresConfigResponse, error) { - rsp, err := c.V1UpdatePostgresConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePostgresConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdatePostgresConfigWithResponse(ctx context.Context, ref string, body V1UpdatePostgresConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePostgresConfigResponse, error) { - rsp, err := c.V1UpdatePostgresConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePostgresConfigResponse(rsp) -} - -// V1GetDatabaseDiskWithResponse request returning *V1GetDatabaseDiskResponse -func (c *ClientWithResponses) V1GetDatabaseDiskWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDatabaseDiskResponse, error) { - rsp, err := c.V1GetDatabaseDisk(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetDatabaseDiskResponse(rsp) -} - -// V1ModifyDatabaseDiskWithBodyWithResponse request with arbitrary body returning *V1ModifyDatabaseDiskResponse -func (c *ClientWithResponses) V1ModifyDatabaseDiskWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ModifyDatabaseDiskResponse, error) { - rsp, err := c.V1ModifyDatabaseDiskWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ModifyDatabaseDiskResponse(rsp) -} - -func (c *ClientWithResponses) V1ModifyDatabaseDiskWithResponse(ctx context.Context, ref string, body V1ModifyDatabaseDiskJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ModifyDatabaseDiskResponse, error) { - rsp, err := c.V1ModifyDatabaseDisk(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ModifyDatabaseDiskResponse(rsp) -} - -// V1GetProjectDiskAutoscaleConfigWithResponse request returning *V1GetProjectDiskAutoscaleConfigResponse -func (c *ClientWithResponses) V1GetProjectDiskAutoscaleConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectDiskAutoscaleConfigResponse, error) { - rsp, err := c.V1GetProjectDiskAutoscaleConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetProjectDiskAutoscaleConfigResponse(rsp) -} - -// V1GetDiskUtilizationWithResponse request returning *V1GetDiskUtilizationResponse -func (c *ClientWithResponses) V1GetDiskUtilizationWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDiskUtilizationResponse, error) { - rsp, err := c.V1GetDiskUtilization(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetDiskUtilizationResponse(rsp) -} - -// V1GetRealtimeConfigWithResponse request returning *V1GetRealtimeConfigResponse -func (c *ClientWithResponses) V1GetRealtimeConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetRealtimeConfigResponse, error) { - rsp, err := c.V1GetRealtimeConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetRealtimeConfigResponse(rsp) -} - -// V1UpdateRealtimeConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateRealtimeConfigResponse -func (c *ClientWithResponses) V1UpdateRealtimeConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateRealtimeConfigResponse, error) { - rsp, err := c.V1UpdateRealtimeConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateRealtimeConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateRealtimeConfigWithResponse(ctx context.Context, ref string, body V1UpdateRealtimeConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateRealtimeConfigResponse, error) { - rsp, err := c.V1UpdateRealtimeConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateRealtimeConfigResponse(rsp) -} - -// V1ShutdownRealtimeWithResponse request returning *V1ShutdownRealtimeResponse -func (c *ClientWithResponses) V1ShutdownRealtimeWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ShutdownRealtimeResponse, error) { - rsp, err := c.V1ShutdownRealtime(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ShutdownRealtimeResponse(rsp) -} - -// V1GetStorageConfigWithResponse request returning *V1GetStorageConfigResponse -func (c *ClientWithResponses) V1GetStorageConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetStorageConfigResponse, error) { - rsp, err := c.V1GetStorageConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetStorageConfigResponse(rsp) -} - -// V1UpdateStorageConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateStorageConfigResponse -func (c *ClientWithResponses) V1UpdateStorageConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateStorageConfigResponse, error) { - rsp, err := c.V1UpdateStorageConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateStorageConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateStorageConfigWithResponse(ctx context.Context, ref string, body V1UpdateStorageConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateStorageConfigResponse, error) { - rsp, err := c.V1UpdateStorageConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateStorageConfigResponse(rsp) -} - -// V1DeleteHostnameConfigWithResponse request returning *V1DeleteHostnameConfigResponse -func (c *ClientWithResponses) V1DeleteHostnameConfigWithResponse(ctx context.Context, ref string, params *V1DeleteHostnameConfigParams, reqEditors ...RequestEditorFn) (*V1DeleteHostnameConfigResponse, error) { - rsp, err := c.V1DeleteHostnameConfig(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteHostnameConfigResponse(rsp) -} - -// V1GetHostnameConfigWithResponse request returning *V1GetHostnameConfigResponse -func (c *ClientWithResponses) V1GetHostnameConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetHostnameConfigResponse, error) { - rsp, err := c.V1GetHostnameConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetHostnameConfigResponse(rsp) -} - -// V1ActivateCustomHostnameWithResponse request returning *V1ActivateCustomHostnameResponse -func (c *ClientWithResponses) V1ActivateCustomHostnameWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ActivateCustomHostnameResponse, error) { - rsp, err := c.V1ActivateCustomHostname(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ActivateCustomHostnameResponse(rsp) -} - -// V1UpdateHostnameConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateHostnameConfigResponse -func (c *ClientWithResponses) V1UpdateHostnameConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateHostnameConfigResponse, error) { - rsp, err := c.V1UpdateHostnameConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateHostnameConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateHostnameConfigWithResponse(ctx context.Context, ref string, body V1UpdateHostnameConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateHostnameConfigResponse, error) { - rsp, err := c.V1UpdateHostnameConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateHostnameConfigResponse(rsp) -} - -// V1VerifyDnsConfigWithResponse request returning *V1VerifyDnsConfigResponse -func (c *ClientWithResponses) V1VerifyDnsConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1VerifyDnsConfigResponse, error) { - rsp, err := c.V1VerifyDnsConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1VerifyDnsConfigResponse(rsp) -} - -// V1ListAllBackupsWithResponse request returning *V1ListAllBackupsResponse -func (c *ClientWithResponses) V1ListAllBackupsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBackupsResponse, error) { - rsp, err := c.V1ListAllBackups(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllBackupsResponse(rsp) -} - -// V1RestorePhysicalBackupWithBodyWithResponse request with arbitrary body returning *V1RestorePhysicalBackupResponse -func (c *ClientWithResponses) V1RestorePhysicalBackupWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RestorePhysicalBackupResponse, error) { - rsp, err := c.V1RestorePhysicalBackupWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestorePhysicalBackupResponse(rsp) -} - -func (c *ClientWithResponses) V1RestorePhysicalBackupWithResponse(ctx context.Context, ref string, body V1RestorePhysicalBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RestorePhysicalBackupResponse, error) { - rsp, err := c.V1RestorePhysicalBackup(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestorePhysicalBackupResponse(rsp) -} - -// V1RestorePitrBackupWithBodyWithResponse request with arbitrary body returning *V1RestorePitrBackupResponse -func (c *ClientWithResponses) V1RestorePitrBackupWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RestorePitrBackupResponse, error) { - rsp, err := c.V1RestorePitrBackupWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestorePitrBackupResponse(rsp) -} - -func (c *ClientWithResponses) V1RestorePitrBackupWithResponse(ctx context.Context, ref string, body V1RestorePitrBackupJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RestorePitrBackupResponse, error) { - rsp, err := c.V1RestorePitrBackup(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestorePitrBackupResponse(rsp) -} - -// V1GetRestorePointWithResponse request returning *V1GetRestorePointResponse -func (c *ClientWithResponses) V1GetRestorePointWithResponse(ctx context.Context, ref string, params *V1GetRestorePointParams, reqEditors ...RequestEditorFn) (*V1GetRestorePointResponse, error) { - rsp, err := c.V1GetRestorePoint(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetRestorePointResponse(rsp) -} - -// V1CreateRestorePointWithBodyWithResponse request with arbitrary body returning *V1CreateRestorePointResponse -func (c *ClientWithResponses) V1CreateRestorePointWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateRestorePointResponse, error) { - rsp, err := c.V1CreateRestorePointWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateRestorePointResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateRestorePointWithResponse(ctx context.Context, ref string, body V1CreateRestorePointJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateRestorePointResponse, error) { - rsp, err := c.V1CreateRestorePoint(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateRestorePointResponse(rsp) -} - -// V1GetBackupScheduleWithResponse request returning *V1GetBackupScheduleResponse -func (c *ClientWithResponses) V1GetBackupScheduleWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetBackupScheduleResponse, error) { - rsp, err := c.V1GetBackupSchedule(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetBackupScheduleResponse(rsp) -} - -// V1UpdateBackupScheduleWithBodyWithResponse request with arbitrary body returning *V1UpdateBackupScheduleResponse -func (c *ClientWithResponses) V1UpdateBackupScheduleWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateBackupScheduleResponse, error) { - rsp, err := c.V1UpdateBackupScheduleWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateBackupScheduleResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateBackupScheduleWithResponse(ctx context.Context, ref string, body V1UpdateBackupScheduleJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateBackupScheduleResponse, error) { - rsp, err := c.V1UpdateBackupSchedule(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateBackupScheduleResponse(rsp) -} - -// V1UndoWithBodyWithResponse request with arbitrary body returning *V1UndoResponse -func (c *ClientWithResponses) V1UndoWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UndoResponse, error) { - rsp, err := c.V1UndoWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UndoResponse(rsp) -} - -func (c *ClientWithResponses) V1UndoWithResponse(ctx context.Context, ref string, body V1UndoJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UndoResponse, error) { - rsp, err := c.V1Undo(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UndoResponse(rsp) -} - -// V1GetDatabaseMetadataWithResponse request returning *V1GetDatabaseMetadataResponse -func (c *ClientWithResponses) V1GetDatabaseMetadataWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetDatabaseMetadataResponse, error) { - rsp, err := c.V1GetDatabaseMetadata(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetDatabaseMetadataResponse(rsp) -} - -// V1GetJitAccessWithResponse request returning *V1GetJitAccessResponse -func (c *ClientWithResponses) V1GetJitAccessWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetJitAccessResponse, error) { - rsp, err := c.V1GetJitAccess(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetJitAccessResponse(rsp) -} - -// V1AuthorizeJitAccessWithBodyWithResponse request with arbitrary body returning *V1AuthorizeJitAccessResponse -func (c *ClientWithResponses) V1AuthorizeJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1AuthorizeJitAccessResponse, error) { - rsp, err := c.V1AuthorizeJitAccessWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1AuthorizeJitAccessResponse(rsp) -} - -func (c *ClientWithResponses) V1AuthorizeJitAccessWithResponse(ctx context.Context, ref string, body V1AuthorizeJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1AuthorizeJitAccessResponse, error) { - rsp, err := c.V1AuthorizeJitAccess(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1AuthorizeJitAccessResponse(rsp) -} - -// V1UpdateJitAccessWithBodyWithResponse request with arbitrary body returning *V1UpdateJitAccessResponse -func (c *ClientWithResponses) V1UpdateJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessResponse, error) { - rsp, err := c.V1UpdateJitAccessWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateJitAccessResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateJitAccessWithResponse(ctx context.Context, ref string, body V1UpdateJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessResponse, error) { - rsp, err := c.V1UpdateJitAccess(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateJitAccessResponse(rsp) -} - -// V1InviteExternalJitAccessWithBodyWithResponse request with arbitrary body returning *V1InviteExternalJitAccessResponse -func (c *ClientWithResponses) V1InviteExternalJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1InviteExternalJitAccessResponse, error) { - rsp, err := c.V1InviteExternalJitAccessWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1InviteExternalJitAccessResponse(rsp) -} - -func (c *ClientWithResponses) V1InviteExternalJitAccessWithResponse(ctx context.Context, ref string, body V1InviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1InviteExternalJitAccessResponse, error) { - rsp, err := c.V1InviteExternalJitAccess(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1InviteExternalJitAccessResponse(rsp) -} - -// V1AcceptInviteExternalJitAccessWithBodyWithResponse request with arbitrary body returning *V1AcceptInviteExternalJitAccessResponse -func (c *ClientWithResponses) V1AcceptInviteExternalJitAccessWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1AcceptInviteExternalJitAccessResponse, error) { - rsp, err := c.V1AcceptInviteExternalJitAccessWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1AcceptInviteExternalJitAccessResponse(rsp) -} - -func (c *ClientWithResponses) V1AcceptInviteExternalJitAccessWithResponse(ctx context.Context, ref string, body V1AcceptInviteExternalJitAccessJSONRequestBody, reqEditors ...RequestEditorFn) (*V1AcceptInviteExternalJitAccessResponse, error) { - rsp, err := c.V1AcceptInviteExternalJitAccess(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1AcceptInviteExternalJitAccessResponse(rsp) -} - -// V1DeleteInviteExternalJitAccessWithResponse request returning *V1DeleteInviteExternalJitAccessResponse -func (c *ClientWithResponses) V1DeleteInviteExternalJitAccessWithResponse(ctx context.Context, ref string, inviteId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteInviteExternalJitAccessResponse, error) { - rsp, err := c.V1DeleteInviteExternalJitAccess(ctx, ref, inviteId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteInviteExternalJitAccessResponse(rsp) -} - -// V1ListJitAccessWithResponse request returning *V1ListJitAccessResponse -func (c *ClientWithResponses) V1ListJitAccessWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListJitAccessResponse, error) { - rsp, err := c.V1ListJitAccess(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListJitAccessResponse(rsp) -} - -// V1DeleteJitAccessWithResponse request returning *V1DeleteJitAccessResponse -func (c *ClientWithResponses) V1DeleteJitAccessWithResponse(ctx context.Context, ref string, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1DeleteJitAccessResponse, error) { - rsp, err := c.V1DeleteJitAccess(ctx, ref, userId, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteJitAccessResponse(rsp) -} - -// V1RollbackMigrationsWithResponse request returning *V1RollbackMigrationsResponse -func (c *ClientWithResponses) V1RollbackMigrationsWithResponse(ctx context.Context, ref string, params *V1RollbackMigrationsParams, reqEditors ...RequestEditorFn) (*V1RollbackMigrationsResponse, error) { - rsp, err := c.V1RollbackMigrations(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RollbackMigrationsResponse(rsp) -} - -// V1ListMigrationHistoryWithResponse request returning *V1ListMigrationHistoryResponse -func (c *ClientWithResponses) V1ListMigrationHistoryWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListMigrationHistoryResponse, error) { - rsp, err := c.V1ListMigrationHistory(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListMigrationHistoryResponse(rsp) -} - -// V1ApplyAMigrationWithBodyWithResponse request with arbitrary body returning *V1ApplyAMigrationResponse -func (c *ClientWithResponses) V1ApplyAMigrationWithBodyWithResponse(ctx context.Context, ref string, params *V1ApplyAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ApplyAMigrationResponse, error) { - rsp, err := c.V1ApplyAMigrationWithBody(ctx, ref, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ApplyAMigrationResponse(rsp) -} - -func (c *ClientWithResponses) V1ApplyAMigrationWithResponse(ctx context.Context, ref string, params *V1ApplyAMigrationParams, body V1ApplyAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ApplyAMigrationResponse, error) { - rsp, err := c.V1ApplyAMigration(ctx, ref, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ApplyAMigrationResponse(rsp) -} - -// V1UpsertAMigrationWithBodyWithResponse request with arbitrary body returning *V1UpsertAMigrationResponse -func (c *ClientWithResponses) V1UpsertAMigrationWithBodyWithResponse(ctx context.Context, ref string, params *V1UpsertAMigrationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpsertAMigrationResponse, error) { - rsp, err := c.V1UpsertAMigrationWithBody(ctx, ref, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpsertAMigrationResponse(rsp) -} - -func (c *ClientWithResponses) V1UpsertAMigrationWithResponse(ctx context.Context, ref string, params *V1UpsertAMigrationParams, body V1UpsertAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpsertAMigrationResponse, error) { - rsp, err := c.V1UpsertAMigration(ctx, ref, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpsertAMigrationResponse(rsp) -} - -// V1GetAMigrationWithResponse request returning *V1GetAMigrationResponse -func (c *ClientWithResponses) V1GetAMigrationWithResponse(ctx context.Context, ref string, version string, reqEditors ...RequestEditorFn) (*V1GetAMigrationResponse, error) { - rsp, err := c.V1GetAMigration(ctx, ref, version, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAMigrationResponse(rsp) -} - -// V1PatchAMigrationWithBodyWithResponse request with arbitrary body returning *V1PatchAMigrationResponse -func (c *ClientWithResponses) V1PatchAMigrationWithBodyWithResponse(ctx context.Context, ref string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PatchAMigrationResponse, error) { - rsp, err := c.V1PatchAMigrationWithBody(ctx, ref, version, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PatchAMigrationResponse(rsp) -} - -func (c *ClientWithResponses) V1PatchAMigrationWithResponse(ctx context.Context, ref string, version string, body V1PatchAMigrationJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PatchAMigrationResponse, error) { - rsp, err := c.V1PatchAMigration(ctx, ref, version, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PatchAMigrationResponse(rsp) -} - -// V1GetDatabaseOpenapiWithResponse request returning *V1GetDatabaseOpenapiResponse -func (c *ClientWithResponses) V1GetDatabaseOpenapiWithResponse(ctx context.Context, ref string, params *V1GetDatabaseOpenapiParams, reqEditors ...RequestEditorFn) (*V1GetDatabaseOpenapiResponse, error) { - rsp, err := c.V1GetDatabaseOpenapi(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetDatabaseOpenapiResponse(rsp) -} - -// V1UpdateDatabasePasswordWithBodyWithResponse request with arbitrary body returning *V1UpdateDatabasePasswordResponse -func (c *ClientWithResponses) V1UpdateDatabasePasswordWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateDatabasePasswordResponse, error) { - rsp, err := c.V1UpdateDatabasePasswordWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateDatabasePasswordResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateDatabasePasswordWithResponse(ctx context.Context, ref string, body V1UpdateDatabasePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateDatabasePasswordResponse, error) { - rsp, err := c.V1UpdateDatabasePassword(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateDatabasePasswordResponse(rsp) -} - -// V1RunAQueryWithBodyWithResponse request with arbitrary body returning *V1RunAQueryResponse -func (c *ClientWithResponses) V1RunAQueryWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RunAQueryResponse, error) { - rsp, err := c.V1RunAQueryWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RunAQueryResponse(rsp) -} - -func (c *ClientWithResponses) V1RunAQueryWithResponse(ctx context.Context, ref string, body V1RunAQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RunAQueryResponse, error) { - rsp, err := c.V1RunAQuery(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RunAQueryResponse(rsp) -} - -// V1ReadOnlyQueryWithBodyWithResponse request with arbitrary body returning *V1ReadOnlyQueryResponse -func (c *ClientWithResponses) V1ReadOnlyQueryWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ReadOnlyQueryResponse, error) { - rsp, err := c.V1ReadOnlyQueryWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ReadOnlyQueryResponse(rsp) -} - -func (c *ClientWithResponses) V1ReadOnlyQueryWithResponse(ctx context.Context, ref string, body V1ReadOnlyQueryJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ReadOnlyQueryResponse, error) { - rsp, err := c.V1ReadOnlyQuery(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ReadOnlyQueryResponse(rsp) -} - -// V1EnableDatabaseWebhookWithResponse request returning *V1EnableDatabaseWebhookResponse -func (c *ClientWithResponses) V1EnableDatabaseWebhookWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1EnableDatabaseWebhookResponse, error) { - rsp, err := c.V1EnableDatabaseWebhook(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1EnableDatabaseWebhookResponse(rsp) -} - -// V1ListAllFunctionsWithResponse request returning *V1ListAllFunctionsResponse -func (c *ClientWithResponses) V1ListAllFunctionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllFunctionsResponse, error) { - rsp, err := c.V1ListAllFunctions(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllFunctionsResponse(rsp) -} - -// V1CreateAFunctionWithBodyWithResponse request with arbitrary body returning *V1CreateAFunctionResponse -func (c *ClientWithResponses) V1CreateAFunctionWithBodyWithResponse(ctx context.Context, ref string, params *V1CreateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CreateAFunctionResponse, error) { - rsp, err := c.V1CreateAFunctionWithBody(ctx, ref, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAFunctionResponse(rsp) -} - -func (c *ClientWithResponses) V1CreateAFunctionWithResponse(ctx context.Context, ref string, params *V1CreateAFunctionParams, body V1CreateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CreateAFunctionResponse, error) { - rsp, err := c.V1CreateAFunction(ctx, ref, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CreateAFunctionResponse(rsp) -} - -// V1BulkUpdateFunctionsWithBodyWithResponse request with arbitrary body returning *V1BulkUpdateFunctionsResponse -func (c *ClientWithResponses) V1BulkUpdateFunctionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkUpdateFunctionsResponse, error) { - rsp, err := c.V1BulkUpdateFunctionsWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkUpdateFunctionsResponse(rsp) -} - -func (c *ClientWithResponses) V1BulkUpdateFunctionsWithResponse(ctx context.Context, ref string, body V1BulkUpdateFunctionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkUpdateFunctionsResponse, error) { - rsp, err := c.V1BulkUpdateFunctions(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkUpdateFunctionsResponse(rsp) -} - -// V1DeployAFunctionWithBodyWithResponse request with arbitrary body returning *V1DeployAFunctionResponse -func (c *ClientWithResponses) V1DeployAFunctionWithBodyWithResponse(ctx context.Context, ref string, params *V1DeployAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1DeployAFunctionResponse, error) { - rsp, err := c.V1DeployAFunctionWithBody(ctx, ref, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeployAFunctionResponse(rsp) -} - -// V1DeleteAFunctionWithResponse request returning *V1DeleteAFunctionResponse -func (c *ClientWithResponses) V1DeleteAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1DeleteAFunctionResponse, error) { - rsp, err := c.V1DeleteAFunction(ctx, ref, functionSlug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteAFunctionResponse(rsp) -} - -// V1GetAFunctionWithResponse request returning *V1GetAFunctionResponse -func (c *ClientWithResponses) V1GetAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1GetAFunctionResponse, error) { - rsp, err := c.V1GetAFunction(ctx, ref, functionSlug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAFunctionResponse(rsp) -} - -// V1UpdateAFunctionWithBodyWithResponse request with arbitrary body returning *V1UpdateAFunctionResponse -func (c *ClientWithResponses) V1UpdateAFunctionWithBodyWithResponse(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateAFunctionResponse, error) { - rsp, err := c.V1UpdateAFunctionWithBody(ctx, ref, functionSlug, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAFunctionResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateAFunctionWithResponse(ctx context.Context, ref string, functionSlug string, params *V1UpdateAFunctionParams, body V1UpdateAFunctionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateAFunctionResponse, error) { - rsp, err := c.V1UpdateAFunction(ctx, ref, functionSlug, params, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateAFunctionResponse(rsp) -} - -// V1GetAFunctionBodyWithResponse request returning *V1GetAFunctionBodyResponse -func (c *ClientWithResponses) V1GetAFunctionBodyWithResponse(ctx context.Context, ref string, functionSlug string, reqEditors ...RequestEditorFn) (*V1GetAFunctionBodyResponse, error) { - rsp, err := c.V1GetAFunctionBody(ctx, ref, functionSlug, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetAFunctionBodyResponse(rsp) -} - -// V1GetServicesHealthWithResponse request returning *V1GetServicesHealthResponse -func (c *ClientWithResponses) V1GetServicesHealthWithResponse(ctx context.Context, ref string, params *V1GetServicesHealthParams, reqEditors ...RequestEditorFn) (*V1GetServicesHealthResponse, error) { - rsp, err := c.V1GetServicesHealth(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetServicesHealthResponse(rsp) -} - -// V1GetJitAccessConfigWithResponse request returning *V1GetJitAccessConfigResponse -func (c *ClientWithResponses) V1GetJitAccessConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetJitAccessConfigResponse, error) { - rsp, err := c.V1GetJitAccessConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetJitAccessConfigResponse(rsp) -} - -// V1UpdateJitAccessConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateJitAccessConfigResponse -func (c *ClientWithResponses) V1UpdateJitAccessConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessConfigResponse, error) { - rsp, err := c.V1UpdateJitAccessConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateJitAccessConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateJitAccessConfigWithResponse(ctx context.Context, ref string, body V1UpdateJitAccessConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateJitAccessConfigResponse, error) { - rsp, err := c.V1UpdateJitAccessConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateJitAccessConfigResponse(rsp) -} - -// V1DeleteNetworkBansWithBodyWithResponse request with arbitrary body returning *V1DeleteNetworkBansResponse -func (c *ClientWithResponses) V1DeleteNetworkBansWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1DeleteNetworkBansResponse, error) { - rsp, err := c.V1DeleteNetworkBansWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteNetworkBansResponse(rsp) -} - -func (c *ClientWithResponses) V1DeleteNetworkBansWithResponse(ctx context.Context, ref string, body V1DeleteNetworkBansJSONRequestBody, reqEditors ...RequestEditorFn) (*V1DeleteNetworkBansResponse, error) { - rsp, err := c.V1DeleteNetworkBans(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeleteNetworkBansResponse(rsp) -} - -// V1ListAllNetworkBansWithResponse request returning *V1ListAllNetworkBansResponse -func (c *ClientWithResponses) V1ListAllNetworkBansWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllNetworkBansResponse, error) { - rsp, err := c.V1ListAllNetworkBans(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllNetworkBansResponse(rsp) -} - -// V1ListAllNetworkBansEnrichedWithResponse request returning *V1ListAllNetworkBansEnrichedResponse -func (c *ClientWithResponses) V1ListAllNetworkBansEnrichedWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllNetworkBansEnrichedResponse, error) { - rsp, err := c.V1ListAllNetworkBansEnriched(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllNetworkBansEnrichedResponse(rsp) -} - -// V1GetNetworkRestrictionsWithResponse request returning *V1GetNetworkRestrictionsResponse -func (c *ClientWithResponses) V1GetNetworkRestrictionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetNetworkRestrictionsResponse, error) { - rsp, err := c.V1GetNetworkRestrictions(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetNetworkRestrictionsResponse(rsp) -} - -// V1PatchNetworkRestrictionsWithBodyWithResponse request with arbitrary body returning *V1PatchNetworkRestrictionsResponse -func (c *ClientWithResponses) V1PatchNetworkRestrictionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1PatchNetworkRestrictionsResponse, error) { - rsp, err := c.V1PatchNetworkRestrictionsWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PatchNetworkRestrictionsResponse(rsp) -} - -func (c *ClientWithResponses) V1PatchNetworkRestrictionsWithResponse(ctx context.Context, ref string, body V1PatchNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1PatchNetworkRestrictionsResponse, error) { - rsp, err := c.V1PatchNetworkRestrictions(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PatchNetworkRestrictionsResponse(rsp) -} - -// V1UpdateNetworkRestrictionsWithBodyWithResponse request with arbitrary body returning *V1UpdateNetworkRestrictionsResponse -func (c *ClientWithResponses) V1UpdateNetworkRestrictionsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateNetworkRestrictionsResponse, error) { - rsp, err := c.V1UpdateNetworkRestrictionsWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateNetworkRestrictionsResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateNetworkRestrictionsWithResponse(ctx context.Context, ref string, body V1UpdateNetworkRestrictionsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateNetworkRestrictionsResponse, error) { - rsp, err := c.V1UpdateNetworkRestrictions(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateNetworkRestrictionsResponse(rsp) -} - -// V1PauseAProjectWithResponse request returning *V1PauseAProjectResponse -func (c *ClientWithResponses) V1PauseAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1PauseAProjectResponse, error) { - rsp, err := c.V1PauseAProject(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1PauseAProjectResponse(rsp) -} - -// V1GetPgsodiumConfigWithResponse request returning *V1GetPgsodiumConfigResponse -func (c *ClientWithResponses) V1GetPgsodiumConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPgsodiumConfigResponse, error) { - rsp, err := c.V1GetPgsodiumConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPgsodiumConfigResponse(rsp) -} - -// V1UpdatePgsodiumConfigWithBodyWithResponse request with arbitrary body returning *V1UpdatePgsodiumConfigResponse -func (c *ClientWithResponses) V1UpdatePgsodiumConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePgsodiumConfigResponse, error) { - rsp, err := c.V1UpdatePgsodiumConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePgsodiumConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdatePgsodiumConfigWithResponse(ctx context.Context, ref string, body V1UpdatePgsodiumConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePgsodiumConfigResponse, error) { - rsp, err := c.V1UpdatePgsodiumConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePgsodiumConfigResponse(rsp) -} - -// V1GetPostgrestServiceConfigWithResponse request returning *V1GetPostgrestServiceConfigResponse -func (c *ClientWithResponses) V1GetPostgrestServiceConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgrestServiceConfigResponse, error) { - rsp, err := c.V1GetPostgrestServiceConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPostgrestServiceConfigResponse(rsp) -} - -// V1UpdatePostgrestServiceConfigWithBodyWithResponse request with arbitrary body returning *V1UpdatePostgrestServiceConfigResponse -func (c *ClientWithResponses) V1UpdatePostgrestServiceConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdatePostgrestServiceConfigResponse, error) { - rsp, err := c.V1UpdatePostgrestServiceConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePostgrestServiceConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdatePostgrestServiceConfigWithResponse(ctx context.Context, ref string, body V1UpdatePostgrestServiceConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdatePostgrestServiceConfigResponse, error) { - rsp, err := c.V1UpdatePostgrestServiceConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdatePostgrestServiceConfigResponse(rsp) -} - -// V1RemoveAReadReplicaWithBodyWithResponse request with arbitrary body returning *V1RemoveAReadReplicaResponse -func (c *ClientWithResponses) V1RemoveAReadReplicaWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1RemoveAReadReplicaResponse, error) { - rsp, err := c.V1RemoveAReadReplicaWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RemoveAReadReplicaResponse(rsp) -} - -func (c *ClientWithResponses) V1RemoveAReadReplicaWithResponse(ctx context.Context, ref string, body V1RemoveAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*V1RemoveAReadReplicaResponse, error) { - rsp, err := c.V1RemoveAReadReplica(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RemoveAReadReplicaResponse(rsp) -} - -// V1SetupAReadReplicaWithBodyWithResponse request with arbitrary body returning *V1SetupAReadReplicaResponse -func (c *ClientWithResponses) V1SetupAReadReplicaWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1SetupAReadReplicaResponse, error) { - rsp, err := c.V1SetupAReadReplicaWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1SetupAReadReplicaResponse(rsp) -} - -func (c *ClientWithResponses) V1SetupAReadReplicaWithResponse(ctx context.Context, ref string, body V1SetupAReadReplicaJSONRequestBody, reqEditors ...RequestEditorFn) (*V1SetupAReadReplicaResponse, error) { - rsp, err := c.V1SetupAReadReplica(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1SetupAReadReplicaResponse(rsp) -} - -// V1GetReadonlyModeStatusWithResponse request returning *V1GetReadonlyModeStatusResponse -func (c *ClientWithResponses) V1GetReadonlyModeStatusWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetReadonlyModeStatusResponse, error) { - rsp, err := c.V1GetReadonlyModeStatus(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetReadonlyModeStatusResponse(rsp) -} - -// V1DisableReadonlyModeTemporarilyWithResponse request returning *V1DisableReadonlyModeTemporarilyResponse -func (c *ClientWithResponses) V1DisableReadonlyModeTemporarilyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DisableReadonlyModeTemporarilyResponse, error) { - rsp, err := c.V1DisableReadonlyModeTemporarily(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DisableReadonlyModeTemporarilyResponse(rsp) -} - -// V1RestartAProjectWithResponse request returning *V1RestartAProjectResponse -func (c *ClientWithResponses) V1RestartAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestartAProjectResponse, error) { - rsp, err := c.V1RestartAProject(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestartAProjectResponse(rsp) -} - -// V1ListAvailableRestoreVersionsWithResponse request returning *V1ListAvailableRestoreVersionsResponse -func (c *ClientWithResponses) V1ListAvailableRestoreVersionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAvailableRestoreVersionsResponse, error) { - rsp, err := c.V1ListAvailableRestoreVersions(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAvailableRestoreVersionsResponse(rsp) -} - -// V1RestoreAProjectWithResponse request returning *V1RestoreAProjectResponse -func (c *ClientWithResponses) V1RestoreAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestoreAProjectResponse, error) { - rsp, err := c.V1RestoreAProject(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1RestoreAProjectResponse(rsp) -} - -// V1CancelAProjectRestorationWithResponse request returning *V1CancelAProjectRestorationResponse -func (c *ClientWithResponses) V1CancelAProjectRestorationWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1CancelAProjectRestorationResponse, error) { - rsp, err := c.V1CancelAProjectRestoration(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CancelAProjectRestorationResponse(rsp) -} - -// V1BulkDeleteSecretsWithBodyWithResponse request with arbitrary body returning *V1BulkDeleteSecretsResponse -func (c *ClientWithResponses) V1BulkDeleteSecretsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkDeleteSecretsResponse, error) { - rsp, err := c.V1BulkDeleteSecretsWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkDeleteSecretsResponse(rsp) -} - -func (c *ClientWithResponses) V1BulkDeleteSecretsWithResponse(ctx context.Context, ref string, body V1BulkDeleteSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkDeleteSecretsResponse, error) { - rsp, err := c.V1BulkDeleteSecrets(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkDeleteSecretsResponse(rsp) -} - -// V1ListAllSecretsWithResponse request returning *V1ListAllSecretsResponse -func (c *ClientWithResponses) V1ListAllSecretsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllSecretsResponse, error) { - rsp, err := c.V1ListAllSecrets(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllSecretsResponse(rsp) -} - -// V1BulkCreateSecretsWithBodyWithResponse request with arbitrary body returning *V1BulkCreateSecretsResponse -func (c *ClientWithResponses) V1BulkCreateSecretsWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1BulkCreateSecretsResponse, error) { - rsp, err := c.V1BulkCreateSecretsWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkCreateSecretsResponse(rsp) -} - -func (c *ClientWithResponses) V1BulkCreateSecretsWithResponse(ctx context.Context, ref string, body V1BulkCreateSecretsJSONRequestBody, reqEditors ...RequestEditorFn) (*V1BulkCreateSecretsResponse, error) { - rsp, err := c.V1BulkCreateSecrets(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1BulkCreateSecretsResponse(rsp) -} - -// V1GetSslEnforcementConfigWithResponse request returning *V1GetSslEnforcementConfigResponse -func (c *ClientWithResponses) V1GetSslEnforcementConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetSslEnforcementConfigResponse, error) { - rsp, err := c.V1GetSslEnforcementConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetSslEnforcementConfigResponse(rsp) -} - -// V1UpdateSslEnforcementConfigWithBodyWithResponse request with arbitrary body returning *V1UpdateSslEnforcementConfigResponse -func (c *ClientWithResponses) V1UpdateSslEnforcementConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpdateSslEnforcementConfigResponse, error) { - rsp, err := c.V1UpdateSslEnforcementConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateSslEnforcementConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1UpdateSslEnforcementConfigWithResponse(ctx context.Context, ref string, body V1UpdateSslEnforcementConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpdateSslEnforcementConfigResponse, error) { - rsp, err := c.V1UpdateSslEnforcementConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpdateSslEnforcementConfigResponse(rsp) -} - -// V1ListAllBucketsWithResponse request returning *V1ListAllBucketsResponse -func (c *ClientWithResponses) V1ListAllBucketsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAllBucketsResponse, error) { - rsp, err := c.V1ListAllBuckets(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllBucketsResponse(rsp) -} - -// V1GenerateTypescriptTypesWithResponse request returning *V1GenerateTypescriptTypesResponse -func (c *ClientWithResponses) V1GenerateTypescriptTypesWithResponse(ctx context.Context, ref string, params *V1GenerateTypescriptTypesParams, reqEditors ...RequestEditorFn) (*V1GenerateTypescriptTypesResponse, error) { - rsp, err := c.V1GenerateTypescriptTypes(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GenerateTypescriptTypesResponse(rsp) -} - -// V1UpgradePostgresVersionWithBodyWithResponse request with arbitrary body returning *V1UpgradePostgresVersionResponse -func (c *ClientWithResponses) V1UpgradePostgresVersionWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1UpgradePostgresVersionResponse, error) { - rsp, err := c.V1UpgradePostgresVersionWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpgradePostgresVersionResponse(rsp) -} - -func (c *ClientWithResponses) V1UpgradePostgresVersionWithResponse(ctx context.Context, ref string, body V1UpgradePostgresVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*V1UpgradePostgresVersionResponse, error) { - rsp, err := c.V1UpgradePostgresVersion(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1UpgradePostgresVersionResponse(rsp) -} - -// V1GetPostgresUpgradeEligibilityWithResponse request returning *V1GetPostgresUpgradeEligibilityResponse -func (c *ClientWithResponses) V1GetPostgresUpgradeEligibilityWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetPostgresUpgradeEligibilityResponse, error) { - rsp, err := c.V1GetPostgresUpgradeEligibility(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPostgresUpgradeEligibilityResponse(rsp) -} - -// V1GetPostgresUpgradeStatusWithResponse request returning *V1GetPostgresUpgradeStatusResponse -func (c *ClientWithResponses) V1GetPostgresUpgradeStatusWithResponse(ctx context.Context, ref string, params *V1GetPostgresUpgradeStatusParams, reqEditors ...RequestEditorFn) (*V1GetPostgresUpgradeStatusResponse, error) { - rsp, err := c.V1GetPostgresUpgradeStatus(ctx, ref, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetPostgresUpgradeStatusResponse(rsp) -} - -// V1DeactivateVanitySubdomainConfigWithResponse request returning *V1DeactivateVanitySubdomainConfigResponse -func (c *ClientWithResponses) V1DeactivateVanitySubdomainConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeactivateVanitySubdomainConfigResponse, error) { - rsp, err := c.V1DeactivateVanitySubdomainConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1DeactivateVanitySubdomainConfigResponse(rsp) -} - -// V1GetVanitySubdomainConfigWithResponse request returning *V1GetVanitySubdomainConfigResponse -func (c *ClientWithResponses) V1GetVanitySubdomainConfigWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetVanitySubdomainConfigResponse, error) { - rsp, err := c.V1GetVanitySubdomainConfig(ctx, ref, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetVanitySubdomainConfigResponse(rsp) -} - -// V1ActivateVanitySubdomainConfigWithBodyWithResponse request with arbitrary body returning *V1ActivateVanitySubdomainConfigResponse -func (c *ClientWithResponses) V1ActivateVanitySubdomainConfigWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1ActivateVanitySubdomainConfigResponse, error) { - rsp, err := c.V1ActivateVanitySubdomainConfigWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ActivateVanitySubdomainConfigResponse(rsp) -} - -func (c *ClientWithResponses) V1ActivateVanitySubdomainConfigWithResponse(ctx context.Context, ref string, body V1ActivateVanitySubdomainConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*V1ActivateVanitySubdomainConfigResponse, error) { - rsp, err := c.V1ActivateVanitySubdomainConfig(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ActivateVanitySubdomainConfigResponse(rsp) -} - -// V1CheckVanitySubdomainAvailabilityWithBodyWithResponse request with arbitrary body returning *V1CheckVanitySubdomainAvailabilityResponse -func (c *ClientWithResponses) V1CheckVanitySubdomainAvailabilityWithBodyWithResponse(ctx context.Context, ref string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1CheckVanitySubdomainAvailabilityResponse, error) { - rsp, err := c.V1CheckVanitySubdomainAvailabilityWithBody(ctx, ref, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CheckVanitySubdomainAvailabilityResponse(rsp) -} - -func (c *ClientWithResponses) V1CheckVanitySubdomainAvailabilityWithResponse(ctx context.Context, ref string, body V1CheckVanitySubdomainAvailabilityJSONRequestBody, reqEditors ...RequestEditorFn) (*V1CheckVanitySubdomainAvailabilityResponse, error) { - rsp, err := c.V1CheckVanitySubdomainAvailability(ctx, ref, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1CheckVanitySubdomainAvailabilityResponse(rsp) -} - -// V1ListAllSnippetsWithResponse request returning *V1ListAllSnippetsResponse -func (c *ClientWithResponses) V1ListAllSnippetsWithResponse(ctx context.Context, params *V1ListAllSnippetsParams, reqEditors ...RequestEditorFn) (*V1ListAllSnippetsResponse, error) { - rsp, err := c.V1ListAllSnippets(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1ListAllSnippetsResponse(rsp) -} - -// V1GetASnippetWithResponse request returning *V1GetASnippetResponse -func (c *ClientWithResponses) V1GetASnippetWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*V1GetASnippetResponse, error) { - rsp, err := c.V1GetASnippet(ctx, id, reqEditors...) - if err != nil { - return nil, err - } - return ParseV1GetASnippetResponse(rsp) -} - -// ParseV1DeleteABranchResponse parses an HTTP response from a V1DeleteABranchWithResponse call -func ParseV1DeleteABranchResponse(rsp *http.Response) (*V1DeleteABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest BranchDeleteResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetABranchConfigResponse parses an HTTP response from a V1GetABranchConfigWithResponse call -func ParseV1GetABranchConfigResponse(rsp *http.Response) (*V1GetABranchConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetABranchConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest BranchDetailResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateABranchConfigResponse parses an HTTP response from a V1UpdateABranchConfigWithResponse call -func ParseV1UpdateABranchConfigResponse(rsp *http.Response) (*V1UpdateABranchConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateABranchConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest BranchResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DiffABranchResponse parses an HTTP response from a V1DiffABranchWithResponse call -func ParseV1DiffABranchResponse(rsp *http.Response) (*V1DiffABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DiffABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1MergeABranchResponse parses an HTTP response from a V1MergeABranchWithResponse call -func ParseV1MergeABranchResponse(rsp *http.Response) (*V1MergeABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1MergeABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest BranchUpdateResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1PushABranchResponse parses an HTTP response from a V1PushABranchWithResponse call -func ParseV1PushABranchResponse(rsp *http.Response) (*V1PushABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1PushABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest BranchUpdateResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1ResetABranchResponse parses an HTTP response from a V1ResetABranchWithResponse call -func ParseV1ResetABranchResponse(rsp *http.Response) (*V1ResetABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ResetABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest BranchUpdateResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1RestoreABranchResponse parses an HTTP response from a V1RestoreABranchWithResponse call -func ParseV1RestoreABranchResponse(rsp *http.Response) (*V1RestoreABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RestoreABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest BranchRestoreResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1AuthorizeUserResponse parses an HTTP response from a V1AuthorizeUserWithResponse call -func ParseV1AuthorizeUserResponse(rsp *http.Response) (*V1AuthorizeUserResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1AuthorizeUserResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1OauthAuthorizeProjectClaimResponse parses an HTTP response from a V1OauthAuthorizeProjectClaimWithResponse call -func ParseV1OauthAuthorizeProjectClaimResponse(rsp *http.Response) (*V1OauthAuthorizeProjectClaimResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1OauthAuthorizeProjectClaimResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1RevokeTokenResponse parses an HTTP response from a V1RevokeTokenWithResponse call -func ParseV1RevokeTokenResponse(rsp *http.Response) (*V1RevokeTokenResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RevokeTokenResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ExchangeOauthTokenResponse parses an HTTP response from a V1ExchangeOauthTokenWithResponse call -func ParseV1ExchangeOauthTokenResponse(rsp *http.Response) (*V1ExchangeOauthTokenResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ExchangeOauthTokenResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest OAuthTokenResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListAllOrganizationsResponse parses an HTTP response from a V1ListAllOrganizationsWithResponse call -func ParseV1ListAllOrganizationsResponse(rsp *http.Response) (*V1ListAllOrganizationsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllOrganizationsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []OrganizationResponseV1Output - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateAnOrganizationResponse parses an HTTP response from a V1CreateAnOrganizationWithResponse call -func ParseV1CreateAnOrganizationResponse(rsp *http.Response) (*V1CreateAnOrganizationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateAnOrganizationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest OrganizationResponseV1Output - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetAnOrganizationResponse parses an HTTP response from a V1GetAnOrganizationWithResponse call -func ParseV1GetAnOrganizationResponse(rsp *http.Response) (*V1GetAnOrganizationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAnOrganizationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1OrganizationSlugResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetOrganizationEntitlementsResponse parses an HTTP response from a V1GetOrganizationEntitlementsWithResponse call -func ParseV1GetOrganizationEntitlementsResponse(rsp *http.Response) (*V1GetOrganizationEntitlementsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetOrganizationEntitlementsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ListEntitlementsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListOrganizationMembersResponse parses an HTTP response from a V1ListOrganizationMembersWithResponse call -func ParseV1ListOrganizationMembersResponse(rsp *http.Response) (*V1ListOrganizationMembersResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListOrganizationMembersResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []V1OrganizationMemberResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetOrganizationProjectClaimResponse parses an HTTP response from a V1GetOrganizationProjectClaimWithResponse call -func ParseV1GetOrganizationProjectClaimResponse(rsp *http.Response) (*V1GetOrganizationProjectClaimResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetOrganizationProjectClaimResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest OrganizationProjectClaimResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ClaimProjectForOrganizationResponse parses an HTTP response from a V1ClaimProjectForOrganizationWithResponse call -func ParseV1ClaimProjectForOrganizationResponse(rsp *http.Response) (*V1ClaimProjectForOrganizationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ClaimProjectForOrganizationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetAllProjectsForOrganizationResponse parses an HTTP response from a V1GetAllProjectsForOrganizationWithResponse call -func ParseV1GetAllProjectsForOrganizationResponse(rsp *http.Response) (*V1GetAllProjectsForOrganizationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAllProjectsForOrganizationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest OrganizationProjectsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProfileResponse parses an HTTP response from a V1GetProfileWithResponse call -func ParseV1GetProfileResponse(rsp *http.Response) (*V1GetProfileResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProfileResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProfileResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListAllProjectsResponse parses an HTTP response from a V1ListAllProjectsWithResponse call -func ParseV1ListAllProjectsResponse(rsp *http.Response) (*V1ListAllProjectsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllProjectsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []V1ProjectWithDatabaseResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateAProjectResponse parses an HTTP response from a V1CreateAProjectWithResponse call -func ParseV1CreateAProjectResponse(rsp *http.Response) (*V1CreateAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest V1ProjectResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetAvailableRegionsResponse parses an HTTP response from a V1GetAvailableRegionsWithResponse call -func ParseV1GetAvailableRegionsResponse(rsp *http.Response) (*V1GetAvailableRegionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAvailableRegionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest RegionsInfoV1Output - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteAProjectResponse parses an HTTP response from a V1DeleteAProjectWithResponse call -func ParseV1DeleteAProjectResponse(rsp *http.Response) (*V1DeleteAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProjectRefResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectResponse parses an HTTP response from a V1GetProjectWithResponse call -func ParseV1GetProjectResponse(rsp *http.Response) (*V1GetProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProjectWithDatabaseResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateAProjectResponse parses an HTTP response from a V1UpdateAProjectWithResponse call -func ParseV1UpdateAProjectResponse(rsp *http.Response) (*V1UpdateAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProjectRefResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListActionRunsResponse parses an HTTP response from a V1ListActionRunsWithResponse call -func ParseV1ListActionRunsResponse(rsp *http.Response) (*V1ListActionRunsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListActionRunsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ListActionRunResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CountActionRunsResponse parses an HTTP response from a V1CountActionRunsWithResponse call -func ParseV1CountActionRunsResponse(rsp *http.Response) (*V1CountActionRunsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CountActionRunsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetActionRunResponse parses an HTTP response from a V1GetActionRunWithResponse call -func ParseV1GetActionRunResponse(rsp *http.Response) (*V1GetActionRunResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetActionRunResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ActionRunResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetActionRunLogsResponse parses an HTTP response from a V1GetActionRunLogsWithResponse call -func ParseV1GetActionRunLogsResponse(rsp *http.Response) (*V1GetActionRunLogsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetActionRunLogsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1UpdateActionRunStatusResponse parses an HTTP response from a V1UpdateActionRunStatusWithResponse call -func ParseV1UpdateActionRunStatusResponse(rsp *http.Response) (*V1UpdateActionRunStatusResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateActionRunStatusResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest UpdateRunStatusResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetPerformanceAdvisorsResponse parses an HTTP response from a V1GetPerformanceAdvisorsWithResponse call -func ParseV1GetPerformanceAdvisorsResponse(rsp *http.Response) (*V1GetPerformanceAdvisorsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPerformanceAdvisorsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProjectAdvisorsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetSecurityAdvisorsResponse parses an HTTP response from a V1GetSecurityAdvisorsWithResponse call -func ParseV1GetSecurityAdvisorsResponse(rsp *http.Response) (*V1GetSecurityAdvisorsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetSecurityAdvisorsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ProjectAdvisorsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectFunctionCombinedStatsResponse parses an HTTP response from a V1GetProjectFunctionCombinedStatsWithResponse call -func ParseV1GetProjectFunctionCombinedStatsResponse(rsp *http.Response) (*V1GetProjectFunctionCombinedStatsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectFunctionCombinedStatsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest AnalyticsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectLogsResponse parses an HTTP response from a V1GetProjectLogsWithResponse call -func ParseV1GetProjectLogsResponse(rsp *http.Response) (*V1GetProjectLogsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectLogsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest AnalyticsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectLogsAllResponse parses an HTTP response from a V1GetProjectLogsAllWithResponse call -func ParseV1GetProjectLogsAllResponse(rsp *http.Response) (*V1GetProjectLogsAllResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectLogsAllResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ScrapeProjectMetricsResponse parses an HTTP response from a V1ScrapeProjectMetricsWithResponse call -func ParseV1ScrapeProjectMetricsResponse(rsp *http.Response) (*V1ScrapeProjectMetricsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ScrapeProjectMetricsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetProjectUsageApiCountResponse parses an HTTP response from a V1GetProjectUsageApiCountWithResponse call -func ParseV1GetProjectUsageApiCountResponse(rsp *http.Response) (*V1GetProjectUsageApiCountResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectUsageApiCountResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1GetUsageApiCountResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectUsageRequestCountResponse parses an HTTP response from a V1GetProjectUsageRequestCountWithResponse call -func ParseV1GetProjectUsageRequestCountResponse(rsp *http.Response) (*V1GetProjectUsageRequestCountResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectUsageRequestCountResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1GetUsageApiRequestsCountResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectApiKeysResponse parses an HTTP response from a V1GetProjectApiKeysWithResponse call -func ParseV1GetProjectApiKeysResponse(rsp *http.Response) (*V1GetProjectApiKeysResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectApiKeysResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []ApiKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateProjectApiKeyResponse parses an HTTP response from a V1CreateProjectApiKeyWithResponse call -func ParseV1CreateProjectApiKeyResponse(rsp *http.Response) (*V1CreateProjectApiKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateProjectApiKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest ApiKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectLegacyApiKeysResponse parses an HTTP response from a V1GetProjectLegacyApiKeysWithResponse call -func ParseV1GetProjectLegacyApiKeysResponse(rsp *http.Response) (*V1GetProjectLegacyApiKeysResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectLegacyApiKeysResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest LegacyApiKeysResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateProjectLegacyApiKeysResponse parses an HTTP response from a V1UpdateProjectLegacyApiKeysWithResponse call -func ParseV1UpdateProjectLegacyApiKeysResponse(rsp *http.Response) (*V1UpdateProjectLegacyApiKeysResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateProjectLegacyApiKeysResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest LegacyApiKeysResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteProjectApiKeyResponse parses an HTTP response from a V1DeleteProjectApiKeyWithResponse call -func ParseV1DeleteProjectApiKeyResponse(rsp *http.Response) (*V1DeleteProjectApiKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteProjectApiKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ApiKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectApiKeyResponse parses an HTTP response from a V1GetProjectApiKeyWithResponse call -func ParseV1GetProjectApiKeyResponse(rsp *http.Response) (*V1GetProjectApiKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectApiKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ApiKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateProjectApiKeyResponse parses an HTTP response from a V1UpdateProjectApiKeyWithResponse call -func ParseV1UpdateProjectApiKeyResponse(rsp *http.Response) (*V1UpdateProjectApiKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateProjectApiKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ApiKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListProjectAddonsResponse parses an HTTP response from a V1ListProjectAddonsWithResponse call -func ParseV1ListProjectAddonsResponse(rsp *http.Response) (*V1ListProjectAddonsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListProjectAddonsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ListProjectAddonsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ApplyProjectAddonResponse parses an HTTP response from a V1ApplyProjectAddonWithResponse call -func ParseV1ApplyProjectAddonResponse(rsp *http.Response) (*V1ApplyProjectAddonResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ApplyProjectAddonResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1RemoveProjectAddonResponse parses an HTTP response from a V1RemoveProjectAddonWithResponse call -func ParseV1RemoveProjectAddonResponse(rsp *http.Response) (*V1RemoveProjectAddonResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RemoveProjectAddonResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1DisablePreviewBranchingResponse parses an HTTP response from a V1DisablePreviewBranchingWithResponse call -func ParseV1DisablePreviewBranchingResponse(rsp *http.Response) (*V1DisablePreviewBranchingResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DisablePreviewBranchingResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListAllBranchesResponse parses an HTTP response from a V1ListAllBranchesWithResponse call -func ParseV1ListAllBranchesResponse(rsp *http.Response) (*V1ListAllBranchesResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllBranchesResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []BranchResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateABranchResponse parses an HTTP response from a V1CreateABranchWithResponse call -func ParseV1CreateABranchResponse(rsp *http.Response) (*V1CreateABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest BranchResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetABranchResponse parses an HTTP response from a V1GetABranchWithResponse call -func ParseV1GetABranchResponse(rsp *http.Response) (*V1GetABranchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetABranchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest BranchResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteProjectClaimTokenResponse parses an HTTP response from a V1DeleteProjectClaimTokenWithResponse call -func ParseV1DeleteProjectClaimTokenResponse(rsp *http.Response) (*V1DeleteProjectClaimTokenResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteProjectClaimTokenResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetProjectClaimTokenResponse parses an HTTP response from a V1GetProjectClaimTokenWithResponse call -func ParseV1GetProjectClaimTokenResponse(rsp *http.Response) (*V1GetProjectClaimTokenResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectClaimTokenResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ProjectClaimTokenResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateProjectClaimTokenResponse parses an HTTP response from a V1CreateProjectClaimTokenWithResponse call -func ParseV1CreateProjectClaimTokenResponse(rsp *http.Response) (*V1CreateProjectClaimTokenResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateProjectClaimTokenResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest CreateProjectClaimTokenResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteLoginRolesResponse parses an HTTP response from a V1DeleteLoginRolesWithResponse call -func ParseV1DeleteLoginRolesResponse(rsp *http.Response) (*V1DeleteLoginRolesResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteLoginRolesResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DeleteRolesResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateLoginRoleResponse parses an HTTP response from a V1CreateLoginRoleWithResponse call -func ParseV1CreateLoginRoleResponse(rsp *http.Response) (*V1CreateLoginRoleResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateLoginRoleResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest CreateRoleResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetAuthServiceConfigResponse parses an HTTP response from a V1GetAuthServiceConfigWithResponse call -func ParseV1GetAuthServiceConfigResponse(rsp *http.Response) (*V1GetAuthServiceConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAuthServiceConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest AuthConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateAuthServiceConfigResponse parses an HTTP response from a V1UpdateAuthServiceConfigWithResponse call -func ParseV1UpdateAuthServiceConfigResponse(rsp *http.Response) (*V1UpdateAuthServiceConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateAuthServiceConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest AuthConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectSigningKeysResponse parses an HTTP response from a V1GetProjectSigningKeysWithResponse call -func ParseV1GetProjectSigningKeysResponse(rsp *http.Response) (*V1GetProjectSigningKeysResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectSigningKeysResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SigningKeysResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateProjectSigningKeyResponse parses an HTTP response from a V1CreateProjectSigningKeyWithResponse call -func ParseV1CreateProjectSigningKeyResponse(rsp *http.Response) (*V1CreateProjectSigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateProjectSigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetLegacySigningKeyResponse parses an HTTP response from a V1GetLegacySigningKeyWithResponse call -func ParseV1GetLegacySigningKeyResponse(rsp *http.Response) (*V1GetLegacySigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetLegacySigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateLegacySigningKeyResponse parses an HTTP response from a V1CreateLegacySigningKeyWithResponse call -func ParseV1CreateLegacySigningKeyResponse(rsp *http.Response) (*V1CreateLegacySigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateLegacySigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1RemoveProjectSigningKeyResponse parses an HTTP response from a V1RemoveProjectSigningKeyWithResponse call -func ParseV1RemoveProjectSigningKeyResponse(rsp *http.Response) (*V1RemoveProjectSigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RemoveProjectSigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectSigningKeyResponse parses an HTTP response from a V1GetProjectSigningKeyWithResponse call -func ParseV1GetProjectSigningKeyResponse(rsp *http.Response) (*V1GetProjectSigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectSigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateProjectSigningKeyResponse parses an HTTP response from a V1UpdateProjectSigningKeyWithResponse call -func ParseV1UpdateProjectSigningKeyResponse(rsp *http.Response) (*V1UpdateProjectSigningKeyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateProjectSigningKeyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SigningKeyResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListAllSsoProviderResponse parses an HTTP response from a V1ListAllSsoProviderWithResponse call -func ParseV1ListAllSsoProviderResponse(rsp *http.Response) (*V1ListAllSsoProviderResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllSsoProviderResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ListProvidersResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateASsoProviderResponse parses an HTTP response from a V1CreateASsoProviderWithResponse call -func ParseV1CreateASsoProviderResponse(rsp *http.Response) (*V1CreateASsoProviderResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateASsoProviderResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest CreateProviderResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1DeleteASsoProviderResponse parses an HTTP response from a V1DeleteASsoProviderWithResponse call -func ParseV1DeleteASsoProviderResponse(rsp *http.Response) (*V1DeleteASsoProviderResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteASsoProviderResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DeleteProviderResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetASsoProviderResponse parses an HTTP response from a V1GetASsoProviderWithResponse call -func ParseV1GetASsoProviderResponse(rsp *http.Response) (*V1GetASsoProviderResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetASsoProviderResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest GetProviderResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateASsoProviderResponse parses an HTTP response from a V1UpdateASsoProviderWithResponse call -func ParseV1UpdateASsoProviderResponse(rsp *http.Response) (*V1UpdateASsoProviderResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateASsoProviderResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest UpdateProviderResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListProjectTpaIntegrationsResponse parses an HTTP response from a V1ListProjectTpaIntegrationsWithResponse call -func ParseV1ListProjectTpaIntegrationsResponse(rsp *http.Response) (*V1ListProjectTpaIntegrationsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListProjectTpaIntegrationsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []ThirdPartyAuthOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateProjectTpaIntegrationResponse parses an HTTP response from a V1CreateProjectTpaIntegrationWithResponse call -func ParseV1CreateProjectTpaIntegrationResponse(rsp *http.Response) (*V1CreateProjectTpaIntegrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateProjectTpaIntegrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest ThirdPartyAuthOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1DeleteProjectTpaIntegrationResponse parses an HTTP response from a V1DeleteProjectTpaIntegrationWithResponse call -func ParseV1DeleteProjectTpaIntegrationResponse(rsp *http.Response) (*V1DeleteProjectTpaIntegrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteProjectTpaIntegrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ThirdPartyAuthOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectTpaIntegrationResponse parses an HTTP response from a V1GetProjectTpaIntegrationWithResponse call -func ParseV1GetProjectTpaIntegrationResponse(rsp *http.Response) (*V1GetProjectTpaIntegrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectTpaIntegrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ThirdPartyAuthOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetProjectPgbouncerConfigResponse parses an HTTP response from a V1GetProjectPgbouncerConfigWithResponse call -func ParseV1GetProjectPgbouncerConfigResponse(rsp *http.Response) (*V1GetProjectPgbouncerConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectPgbouncerConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1PgbouncerConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetPoolerConfigResponse parses an HTTP response from a V1GetPoolerConfigWithResponse call -func ParseV1GetPoolerConfigResponse(rsp *http.Response) (*V1GetPoolerConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPoolerConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []SupavisorConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdatePoolerConfigResponse parses an HTTP response from a V1UpdatePoolerConfigWithResponse call -func ParseV1UpdatePoolerConfigResponse(rsp *http.Response) (*V1UpdatePoolerConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdatePoolerConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest UpdateSupavisorConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetPostgresConfigResponse parses an HTTP response from a V1GetPostgresConfigWithResponse call -func ParseV1GetPostgresConfigResponse(rsp *http.Response) (*V1GetPostgresConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPostgresConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PostgresConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdatePostgresConfigResponse parses an HTTP response from a V1UpdatePostgresConfigWithResponse call -func ParseV1UpdatePostgresConfigResponse(rsp *http.Response) (*V1UpdatePostgresConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdatePostgresConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PostgresConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetDatabaseDiskResponse parses an HTTP response from a V1GetDatabaseDiskWithResponse call -func ParseV1GetDatabaseDiskResponse(rsp *http.Response) (*V1GetDatabaseDiskResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetDatabaseDiskResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DiskResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ModifyDatabaseDiskResponse parses an HTTP response from a V1ModifyDatabaseDiskWithResponse call -func ParseV1ModifyDatabaseDiskResponse(rsp *http.Response) (*V1ModifyDatabaseDiskResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ModifyDatabaseDiskResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetProjectDiskAutoscaleConfigResponse parses an HTTP response from a V1GetProjectDiskAutoscaleConfigWithResponse call -func ParseV1GetProjectDiskAutoscaleConfigResponse(rsp *http.Response) (*V1GetProjectDiskAutoscaleConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetProjectDiskAutoscaleConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DiskAutoscaleConfigOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetDiskUtilizationResponse parses an HTTP response from a V1GetDiskUtilizationWithResponse call -func ParseV1GetDiskUtilizationResponse(rsp *http.Response) (*V1GetDiskUtilizationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetDiskUtilizationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DiskUtilMetricsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetRealtimeConfigResponse parses an HTTP response from a V1GetRealtimeConfigWithResponse call -func ParseV1GetRealtimeConfigResponse(rsp *http.Response) (*V1GetRealtimeConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetRealtimeConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest RealtimeConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateRealtimeConfigResponse parses an HTTP response from a V1UpdateRealtimeConfigWithResponse call -func ParseV1UpdateRealtimeConfigResponse(rsp *http.Response) (*V1UpdateRealtimeConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateRealtimeConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ShutdownRealtimeResponse parses an HTTP response from a V1ShutdownRealtimeWithResponse call -func ParseV1ShutdownRealtimeResponse(rsp *http.Response) (*V1ShutdownRealtimeResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ShutdownRealtimeResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetStorageConfigResponse parses an HTTP response from a V1GetStorageConfigWithResponse call -func ParseV1GetStorageConfigResponse(rsp *http.Response) (*V1GetStorageConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetStorageConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest StorageConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateStorageConfigResponse parses an HTTP response from a V1UpdateStorageConfigWithResponse call -func ParseV1UpdateStorageConfigResponse(rsp *http.Response) (*V1UpdateStorageConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateStorageConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1DeleteHostnameConfigResponse parses an HTTP response from a V1DeleteHostnameConfigWithResponse call -func ParseV1DeleteHostnameConfigResponse(rsp *http.Response) (*V1DeleteHostnameConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteHostnameConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetHostnameConfigResponse parses an HTTP response from a V1GetHostnameConfigWithResponse call -func ParseV1GetHostnameConfigResponse(rsp *http.Response) (*V1GetHostnameConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetHostnameConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest UpdateCustomHostnameResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ActivateCustomHostnameResponse parses an HTTP response from a V1ActivateCustomHostnameWithResponse call -func ParseV1ActivateCustomHostnameResponse(rsp *http.Response) (*V1ActivateCustomHostnameResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ActivateCustomHostnameResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest UpdateCustomHostnameResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1UpdateHostnameConfigResponse parses an HTTP response from a V1UpdateHostnameConfigWithResponse call -func ParseV1UpdateHostnameConfigResponse(rsp *http.Response) (*V1UpdateHostnameConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateHostnameConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest UpdateCustomHostnameResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1VerifyDnsConfigResponse parses an HTTP response from a V1VerifyDnsConfigWithResponse call -func ParseV1VerifyDnsConfigResponse(rsp *http.Response) (*V1VerifyDnsConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1VerifyDnsConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest UpdateCustomHostnameResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1ListAllBackupsResponse parses an HTTP response from a V1ListAllBackupsWithResponse call -func ParseV1ListAllBackupsResponse(rsp *http.Response) (*V1ListAllBackupsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllBackupsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1BackupsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1RestorePhysicalBackupResponse parses an HTTP response from a V1RestorePhysicalBackupWithResponse call -func ParseV1RestorePhysicalBackupResponse(rsp *http.Response) (*V1RestorePhysicalBackupResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RestorePhysicalBackupResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1RestorePitrBackupResponse parses an HTTP response from a V1RestorePitrBackupWithResponse call -func ParseV1RestorePitrBackupResponse(rsp *http.Response) (*V1RestorePitrBackupResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RestorePitrBackupResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetRestorePointResponse parses an HTTP response from a V1GetRestorePointWithResponse call -func ParseV1GetRestorePointResponse(rsp *http.Response) (*V1GetRestorePointResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetRestorePointResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1RestorePointResponse - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateRestorePointResponse parses an HTTP response from a V1CreateRestorePointWithResponse call -func ParseV1CreateRestorePointResponse(rsp *http.Response) (*V1CreateRestorePointResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateRestorePointResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest V1RestorePointResponse - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetBackupScheduleResponse parses an HTTP response from a V1GetBackupScheduleWithResponse call -func ParseV1GetBackupScheduleResponse(rsp *http.Response) (*V1GetBackupScheduleResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetBackupScheduleResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1BackupScheduleResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON402 = &dest - - } - - return response, nil -} - -// ParseV1UpdateBackupScheduleResponse parses an HTTP response from a V1UpdateBackupScheduleWithResponse call -func ParseV1UpdateBackupScheduleResponse(rsp *http.Response) (*V1UpdateBackupScheduleResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateBackupScheduleResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1BackupScheduleResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON402 = &dest - - } - - return response, nil -} - -// ParseV1UndoResponse parses an HTTP response from a V1UndoWithResponse call -func ParseV1UndoResponse(rsp *http.Response) (*V1UndoResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UndoResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetDatabaseMetadataResponse parses an HTTP response from a V1GetDatabaseMetadataWithResponse call -func ParseV1GetDatabaseMetadataResponse(rsp *http.Response) (*V1GetDatabaseMetadataResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetDatabaseMetadataResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest GetProjectDbMetadataResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetJitAccessResponse parses an HTTP response from a V1GetJitAccessWithResponse call -func ParseV1GetJitAccessResponse(rsp *http.Response) (*V1GetJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAccessResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1AuthorizeJitAccessResponse parses an HTTP response from a V1AuthorizeJitAccessWithResponse call -func ParseV1AuthorizeJitAccessResponse(rsp *http.Response) (*V1AuthorizeJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1AuthorizeJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAuthorizeAccessResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateJitAccessResponse parses an HTTP response from a V1UpdateJitAccessWithResponse call -func ParseV1UpdateJitAccessResponse(rsp *http.Response) (*V1UpdateJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAccessResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1InviteExternalJitAccessResponse parses an HTTP response from a V1InviteExternalJitAccessWithResponse call -func ParseV1InviteExternalJitAccessResponse(rsp *http.Response) (*V1InviteExternalJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1InviteExternalJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest InviteExternalUserJitResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1AcceptInviteExternalJitAccessResponse parses an HTTP response from a V1AcceptInviteExternalJitAccessWithResponse call -func ParseV1AcceptInviteExternalJitAccessResponse(rsp *http.Response) (*V1AcceptInviteExternalJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1AcceptInviteExternalJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAccessResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteInviteExternalJitAccessResponse parses an HTTP response from a V1DeleteInviteExternalJitAccessWithResponse call -func ParseV1DeleteInviteExternalJitAccessResponse(rsp *http.Response) (*V1DeleteInviteExternalJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteInviteExternalJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListJitAccessResponse parses an HTTP response from a V1ListJitAccessWithResponse call -func ParseV1ListJitAccessResponse(rsp *http.Response) (*V1ListJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitListAccessResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteJitAccessResponse parses an HTTP response from a V1DeleteJitAccessWithResponse call -func ParseV1DeleteJitAccessResponse(rsp *http.Response) (*V1DeleteJitAccessResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteJitAccessResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1RollbackMigrationsResponse parses an HTTP response from a V1RollbackMigrationsWithResponse call -func ParseV1RollbackMigrationsResponse(rsp *http.Response) (*V1RollbackMigrationsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RollbackMigrationsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListMigrationHistoryResponse parses an HTTP response from a V1ListMigrationHistoryWithResponse call -func ParseV1ListMigrationHistoryResponse(rsp *http.Response) (*V1ListMigrationHistoryResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListMigrationHistoryResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1ListMigrationsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ApplyAMigrationResponse parses an HTTP response from a V1ApplyAMigrationWithResponse call -func ParseV1ApplyAMigrationResponse(rsp *http.Response) (*V1ApplyAMigrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ApplyAMigrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1UpsertAMigrationResponse parses an HTTP response from a V1UpsertAMigrationWithResponse call -func ParseV1UpsertAMigrationResponse(rsp *http.Response) (*V1UpsertAMigrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpsertAMigrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetAMigrationResponse parses an HTTP response from a V1GetAMigrationWithResponse call -func ParseV1GetAMigrationResponse(rsp *http.Response) (*V1GetAMigrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAMigrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1GetMigrationResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1PatchAMigrationResponse parses an HTTP response from a V1PatchAMigrationWithResponse call -func ParseV1PatchAMigrationResponse(rsp *http.Response) (*V1PatchAMigrationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1PatchAMigrationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetDatabaseOpenapiResponse parses an HTTP response from a V1GetDatabaseOpenapiWithResponse call -func ParseV1GetDatabaseOpenapiResponse(rsp *http.Response) (*V1GetDatabaseOpenapiResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetDatabaseOpenapiResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest map[string]interface{} - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateDatabasePasswordResponse parses an HTTP response from a V1UpdateDatabasePasswordWithResponse call -func ParseV1UpdateDatabasePasswordResponse(rsp *http.Response) (*V1UpdateDatabasePasswordResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateDatabasePasswordResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1UpdatePasswordResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1RunAQueryResponse parses an HTTP response from a V1RunAQueryWithResponse call -func ParseV1RunAQueryResponse(rsp *http.Response) (*V1RunAQueryResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RunAQueryResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ReadOnlyQueryResponse parses an HTTP response from a V1ReadOnlyQueryWithResponse call -func ParseV1ReadOnlyQueryResponse(rsp *http.Response) (*V1ReadOnlyQueryResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ReadOnlyQueryResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1EnableDatabaseWebhookResponse parses an HTTP response from a V1EnableDatabaseWebhookWithResponse call -func ParseV1EnableDatabaseWebhookResponse(rsp *http.Response) (*V1EnableDatabaseWebhookResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1EnableDatabaseWebhookResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListAllFunctionsResponse parses an HTTP response from a V1ListAllFunctionsWithResponse call -func ParseV1ListAllFunctionsResponse(rsp *http.Response) (*V1ListAllFunctionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllFunctionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []FunctionResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1CreateAFunctionResponse parses an HTTP response from a V1CreateAFunctionWithResponse call -func ParseV1CreateAFunctionResponse(rsp *http.Response) (*V1CreateAFunctionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CreateAFunctionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest FunctionResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1BulkUpdateFunctionsResponse parses an HTTP response from a V1BulkUpdateFunctionsWithResponse call -func ParseV1BulkUpdateFunctionsResponse(rsp *http.Response) (*V1BulkUpdateFunctionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1BulkUpdateFunctionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest BulkUpdateFunctionResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeployAFunctionResponse parses an HTTP response from a V1DeployAFunctionWithResponse call -func ParseV1DeployAFunctionResponse(rsp *http.Response) (*V1DeployAFunctionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeployAFunctionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest DeployFunctionResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1DeleteAFunctionResponse parses an HTTP response from a V1DeleteAFunctionWithResponse call -func ParseV1DeleteAFunctionResponse(rsp *http.Response) (*V1DeleteAFunctionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteAFunctionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetAFunctionResponse parses an HTTP response from a V1GetAFunctionWithResponse call -func ParseV1GetAFunctionResponse(rsp *http.Response) (*V1GetAFunctionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAFunctionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest FunctionSlugResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateAFunctionResponse parses an HTTP response from a V1UpdateAFunctionWithResponse call -func ParseV1UpdateAFunctionResponse(rsp *http.Response) (*V1UpdateAFunctionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateAFunctionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest FunctionSlugResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetAFunctionBodyResponse parses an HTTP response from a V1GetAFunctionBodyWithResponse call -func ParseV1GetAFunctionBodyResponse(rsp *http.Response) (*V1GetAFunctionBodyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetAFunctionBodyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest StreamableFile - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetServicesHealthResponse parses an HTTP response from a V1GetServicesHealthWithResponse call -func ParseV1GetServicesHealthResponse(rsp *http.Response) (*V1GetServicesHealthResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetServicesHealthResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []V1ServiceHealthResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetJitAccessConfigResponse parses an HTTP response from a V1GetJitAccessConfigWithResponse call -func ParseV1GetJitAccessConfigResponse(rsp *http.Response) (*V1GetJitAccessConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetJitAccessConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1GetJitAccessConfig200JSONResponseBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateJitAccessConfigResponse parses an HTTP response from a V1UpdateJitAccessConfigWithResponse call -func ParseV1UpdateJitAccessConfigResponse(rsp *http.Response) (*V1UpdateJitAccessConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateJitAccessConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1UpdateJitAccessConfig200JSONResponseBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeleteNetworkBansResponse parses an HTTP response from a V1DeleteNetworkBansWithResponse call -func ParseV1DeleteNetworkBansResponse(rsp *http.Response) (*V1DeleteNetworkBansResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeleteNetworkBansResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListAllNetworkBansResponse parses an HTTP response from a V1ListAllNetworkBansWithResponse call -func ParseV1ListAllNetworkBansResponse(rsp *http.Response) (*V1ListAllNetworkBansResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllNetworkBansResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest NetworkBanResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1ListAllNetworkBansEnrichedResponse parses an HTTP response from a V1ListAllNetworkBansEnrichedWithResponse call -func ParseV1ListAllNetworkBansEnrichedResponse(rsp *http.Response) (*V1ListAllNetworkBansEnrichedResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllNetworkBansEnrichedResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest NetworkBanResponseEnrichedOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetNetworkRestrictionsResponse parses an HTTP response from a V1GetNetworkRestrictionsWithResponse call -func ParseV1GetNetworkRestrictionsResponse(rsp *http.Response) (*V1GetNetworkRestrictionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetNetworkRestrictionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest NetworkRestrictionsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1PatchNetworkRestrictionsResponse parses an HTTP response from a V1PatchNetworkRestrictionsWithResponse call -func ParseV1PatchNetworkRestrictionsResponse(rsp *http.Response) (*V1PatchNetworkRestrictionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1PatchNetworkRestrictionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest NetworkRestrictionsV2ResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateNetworkRestrictionsResponse parses an HTTP response from a V1UpdateNetworkRestrictionsWithResponse call -func ParseV1UpdateNetworkRestrictionsResponse(rsp *http.Response) (*V1UpdateNetworkRestrictionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateNetworkRestrictionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest NetworkRestrictionsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1PauseAProjectResponse parses an HTTP response from a V1PauseAProjectWithResponse call -func ParseV1PauseAProjectResponse(rsp *http.Response) (*V1PauseAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1PauseAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetPgsodiumConfigResponse parses an HTTP response from a V1GetPgsodiumConfigWithResponse call -func ParseV1GetPgsodiumConfigResponse(rsp *http.Response) (*V1GetPgsodiumConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPgsodiumConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PgsodiumConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdatePgsodiumConfigResponse parses an HTTP response from a V1UpdatePgsodiumConfigWithResponse call -func ParseV1UpdatePgsodiumConfigResponse(rsp *http.Response) (*V1UpdatePgsodiumConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdatePgsodiumConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PgsodiumConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetPostgrestServiceConfigResponse parses an HTTP response from a V1GetPostgrestServiceConfigWithResponse call -func ParseV1GetPostgrestServiceConfigResponse(rsp *http.Response) (*V1GetPostgrestServiceConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPostgrestServiceConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PostgrestConfigWithJWTSecretResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdatePostgrestServiceConfigResponse parses an HTTP response from a V1UpdatePostgrestServiceConfigWithResponse call -func ParseV1UpdatePostgrestServiceConfigResponse(rsp *http.Response) (*V1UpdatePostgrestServiceConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdatePostgrestServiceConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest V1PostgrestConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1RemoveAReadReplicaResponse parses an HTTP response from a V1RemoveAReadReplicaWithResponse call -func ParseV1RemoveAReadReplicaResponse(rsp *http.Response) (*V1RemoveAReadReplicaResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RemoveAReadReplicaResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1SetupAReadReplicaResponse parses an HTTP response from a V1SetupAReadReplicaWithResponse call -func ParseV1SetupAReadReplicaResponse(rsp *http.Response) (*V1SetupAReadReplicaResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1SetupAReadReplicaResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON402 = &dest - - } - - return response, nil -} - -// ParseV1GetReadonlyModeStatusResponse parses an HTTP response from a V1GetReadonlyModeStatusWithResponse call -func ParseV1GetReadonlyModeStatusResponse(rsp *http.Response) (*V1GetReadonlyModeStatusResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetReadonlyModeStatusResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ReadOnlyStatusResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DisableReadonlyModeTemporarilyResponse parses an HTTP response from a V1DisableReadonlyModeTemporarilyWithResponse call -func ParseV1DisableReadonlyModeTemporarilyResponse(rsp *http.Response) (*V1DisableReadonlyModeTemporarilyResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DisableReadonlyModeTemporarilyResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1RestartAProjectResponse parses an HTTP response from a V1RestartAProjectWithResponse call -func ParseV1RestartAProjectResponse(rsp *http.Response) (*V1RestartAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RestartAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListAvailableRestoreVersionsResponse parses an HTTP response from a V1ListAvailableRestoreVersionsWithResponse call -func ParseV1ListAvailableRestoreVersionsResponse(rsp *http.Response) (*V1ListAvailableRestoreVersionsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAvailableRestoreVersionsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest GetProjectAvailableRestoreVersionsResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1RestoreAProjectResponse parses an HTTP response from a V1RestoreAProjectWithResponse call -func ParseV1RestoreAProjectResponse(rsp *http.Response) (*V1RestoreAProjectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1RestoreAProjectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1CancelAProjectRestorationResponse parses an HTTP response from a V1CancelAProjectRestorationWithResponse call -func ParseV1CancelAProjectRestorationResponse(rsp *http.Response) (*V1CancelAProjectRestorationResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CancelAProjectRestorationResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1BulkDeleteSecretsResponse parses an HTTP response from a V1BulkDeleteSecretsWithResponse call -func ParseV1BulkDeleteSecretsResponse(rsp *http.Response) (*V1BulkDeleteSecretsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1BulkDeleteSecretsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1ListAllSecretsResponse parses an HTTP response from a V1ListAllSecretsWithResponse call -func ParseV1ListAllSecretsResponse(rsp *http.Response) (*V1ListAllSecretsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllSecretsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []SecretResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1BulkCreateSecretsResponse parses an HTTP response from a V1BulkCreateSecretsWithResponse call -func ParseV1BulkCreateSecretsResponse(rsp *http.Response) (*V1BulkCreateSecretsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1BulkCreateSecretsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetSslEnforcementConfigResponse parses an HTTP response from a V1GetSslEnforcementConfigWithResponse call -func ParseV1GetSslEnforcementConfigResponse(rsp *http.Response) (*V1GetSslEnforcementConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetSslEnforcementConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SslEnforcementResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpdateSslEnforcementConfigResponse parses an HTTP response from a V1UpdateSslEnforcementConfigWithResponse call -func ParseV1UpdateSslEnforcementConfigResponse(rsp *http.Response) (*V1UpdateSslEnforcementConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpdateSslEnforcementConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SslEnforcementResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1ListAllBucketsResponse parses an HTTP response from a V1ListAllBucketsWithResponse call -func ParseV1ListAllBucketsResponse(rsp *http.Response) (*V1ListAllBucketsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllBucketsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []V1StorageBucketResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GenerateTypescriptTypesResponse parses an HTTP response from a V1GenerateTypescriptTypesWithResponse call -func ParseV1GenerateTypescriptTypesResponse(rsp *http.Response) (*V1GenerateTypescriptTypesResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GenerateTypescriptTypesResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest TypescriptResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1UpgradePostgresVersionResponse parses an HTTP response from a V1UpgradePostgresVersionWithResponse call -func ParseV1UpgradePostgresVersionResponse(rsp *http.Response) (*V1UpgradePostgresVersionResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1UpgradePostgresVersionResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest ProjectUpgradeInitiateResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - } - - return response, nil -} - -// ParseV1GetPostgresUpgradeEligibilityResponse parses an HTTP response from a V1GetPostgresUpgradeEligibilityWithResponse call -func ParseV1GetPostgresUpgradeEligibilityResponse(rsp *http.Response) (*V1GetPostgresUpgradeEligibilityResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPostgresUpgradeEligibilityResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ProjectUpgradeEligibilityResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetPostgresUpgradeStatusResponse parses an HTTP response from a V1GetPostgresUpgradeStatusWithResponse call -func ParseV1GetPostgresUpgradeStatusResponse(rsp *http.Response) (*V1GetPostgresUpgradeStatusResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetPostgresUpgradeStatusResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DatabaseUpgradeStatusResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1DeactivateVanitySubdomainConfigResponse parses an HTTP response from a V1DeactivateVanitySubdomainConfigWithResponse call -func ParseV1DeactivateVanitySubdomainConfigResponse(rsp *http.Response) (*V1DeactivateVanitySubdomainConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1DeactivateVanitySubdomainConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - return response, nil -} - -// ParseV1GetVanitySubdomainConfigResponse parses an HTTP response from a V1GetVanitySubdomainConfigWithResponse call -func ParseV1GetVanitySubdomainConfigResponse(rsp *http.Response) (*V1GetVanitySubdomainConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetVanitySubdomainConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest VanitySubdomainConfigResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON400 = &dest - - } - - return response, nil -} - -// ParseV1ActivateVanitySubdomainConfigResponse parses an HTTP response from a V1ActivateVanitySubdomainConfigWithResponse call -func ParseV1ActivateVanitySubdomainConfigResponse(rsp *http.Response) (*V1ActivateVanitySubdomainConfigResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ActivateVanitySubdomainConfigResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest ActivateVanitySubdomainResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON400 = &dest - - } - - return response, nil -} - -// ParseV1CheckVanitySubdomainAvailabilityResponse parses an HTTP response from a V1CheckVanitySubdomainAvailabilityWithResponse call -func ParseV1CheckVanitySubdomainAvailabilityResponse(rsp *http.Response) (*V1CheckVanitySubdomainAvailabilityResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1CheckVanitySubdomainAvailabilityResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest SubdomainAvailabilityResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest PlanGateErrorBody - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON400 = &dest - - } - - return response, nil -} - -// ParseV1ListAllSnippetsResponse parses an HTTP response from a V1ListAllSnippetsWithResponse call -func ParseV1ListAllSnippetsResponse(rsp *http.Response) (*V1ListAllSnippetsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1ListAllSnippetsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SnippetListOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} - -// ParseV1GetASnippetResponse parses an HTTP response from a V1GetASnippetWithResponse call -func ParseV1GetASnippetResponse(rsp *http.Response) (*V1GetASnippetResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &V1GetASnippetResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SnippetResponseOutput - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} diff --git a/apps/cli-go/pkg/api/types.cfg.yaml b/apps/cli-go/pkg/api/types.cfg.yaml deleted file mode 100755 index 128f4a387b..0000000000 --- a/apps/cli-go/pkg/api/types.cfg.yaml +++ /dev/null @@ -1,8 +0,0 @@ -package: api -generate: - models: true -output-options: - nullable-type: true - overlay: - path: api/overlay.yaml -output: pkg/api/types.gen.go diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go deleted file mode 100644 index f221e809fb..0000000000 --- a/apps/cli-go/pkg/api/types.gen.go +++ /dev/null @@ -1,11694 +0,0 @@ -// Package api provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. -package api - -import ( - "encoding/json" - "time" - - "github.com/oapi-codegen/nullable" - "github.com/oapi-codegen/runtime" - openapi_types "github.com/oapi-codegen/runtime/types" -) - -const ( - BearerScopes bearerContextKey = "bearer.Scopes" -) - -// Defines values for ActionRunResponseOutputRunStepsName. -const ( - ActionRunResponseOutputRunStepsNameClone ActionRunResponseOutputRunStepsName = "clone" - ActionRunResponseOutputRunStepsNameConfigure ActionRunResponseOutputRunStepsName = "configure" - ActionRunResponseOutputRunStepsNameDeploy ActionRunResponseOutputRunStepsName = "deploy" - ActionRunResponseOutputRunStepsNameHealth ActionRunResponseOutputRunStepsName = "health" - ActionRunResponseOutputRunStepsNameMigrate ActionRunResponseOutputRunStepsName = "migrate" - ActionRunResponseOutputRunStepsNamePull ActionRunResponseOutputRunStepsName = "pull" - ActionRunResponseOutputRunStepsNameSeed ActionRunResponseOutputRunStepsName = "seed" -) - -// Valid indicates whether the value is a known member of the ActionRunResponseOutputRunStepsName enum. -func (e ActionRunResponseOutputRunStepsName) Valid() bool { - switch e { - case ActionRunResponseOutputRunStepsNameClone: - return true - case ActionRunResponseOutputRunStepsNameConfigure: - return true - case ActionRunResponseOutputRunStepsNameDeploy: - return true - case ActionRunResponseOutputRunStepsNameHealth: - return true - case ActionRunResponseOutputRunStepsNameMigrate: - return true - case ActionRunResponseOutputRunStepsNamePull: - return true - case ActionRunResponseOutputRunStepsNameSeed: - return true - default: - return false - } -} - -// Defines values for ActionRunResponseOutputRunStepsStatus. -const ( - ActionRunResponseOutputRunStepsStatusCREATED ActionRunResponseOutputRunStepsStatus = "CREATED" - ActionRunResponseOutputRunStepsStatusDEAD ActionRunResponseOutputRunStepsStatus = "DEAD" - ActionRunResponseOutputRunStepsStatusEXITED ActionRunResponseOutputRunStepsStatus = "EXITED" - ActionRunResponseOutputRunStepsStatusPAUSED ActionRunResponseOutputRunStepsStatus = "PAUSED" - ActionRunResponseOutputRunStepsStatusREMOVING ActionRunResponseOutputRunStepsStatus = "REMOVING" - ActionRunResponseOutputRunStepsStatusRESTARTING ActionRunResponseOutputRunStepsStatus = "RESTARTING" - ActionRunResponseOutputRunStepsStatusRUNNING ActionRunResponseOutputRunStepsStatus = "RUNNING" -) - -// Valid indicates whether the value is a known member of the ActionRunResponseOutputRunStepsStatus enum. -func (e ActionRunResponseOutputRunStepsStatus) Valid() bool { - switch e { - case ActionRunResponseOutputRunStepsStatusCREATED: - return true - case ActionRunResponseOutputRunStepsStatusDEAD: - return true - case ActionRunResponseOutputRunStepsStatusEXITED: - return true - case ActionRunResponseOutputRunStepsStatusPAUSED: - return true - case ActionRunResponseOutputRunStepsStatusREMOVING: - return true - case ActionRunResponseOutputRunStepsStatusRESTARTING: - return true - case ActionRunResponseOutputRunStepsStatusRUNNING: - return true - default: - return false - } -} - -// Defines values for ApiKeyResponseOutputType. -const ( - ApiKeyResponseOutputTypeLegacy ApiKeyResponseOutputType = "legacy" - ApiKeyResponseOutputTypeLessThannil ApiKeyResponseOutputType = "" - ApiKeyResponseOutputTypePublishable ApiKeyResponseOutputType = "publishable" - ApiKeyResponseOutputTypeSecret ApiKeyResponseOutputType = "secret" -) - -// Valid indicates whether the value is a known member of the ApiKeyResponseOutputType enum. -func (e ApiKeyResponseOutputType) Valid() bool { - switch e { - case ApiKeyResponseOutputTypeLegacy: - return true - case ApiKeyResponseOutputTypeLessThannil: - return true - case ApiKeyResponseOutputTypePublishable: - return true - case ApiKeyResponseOutputTypeSecret: - return true - default: - return false - } -} - -// Defines values for ApplyProjectAddonBodyAddonType. -const ( - ApplyProjectAddonBodyAddonTypeAuthMfaPhone ApplyProjectAddonBodyAddonType = "auth_mfa_phone" - ApplyProjectAddonBodyAddonTypeAuthMfaWebAuthn ApplyProjectAddonBodyAddonType = "auth_mfa_web_authn" - ApplyProjectAddonBodyAddonTypeComputeInstance ApplyProjectAddonBodyAddonType = "compute_instance" - ApplyProjectAddonBodyAddonTypeCustomDomain ApplyProjectAddonBodyAddonType = "custom_domain" - ApplyProjectAddonBodyAddonTypeEtlPipeline ApplyProjectAddonBodyAddonType = "etl_pipeline" - ApplyProjectAddonBodyAddonTypeIpv4 ApplyProjectAddonBodyAddonType = "ipv4" - ApplyProjectAddonBodyAddonTypeLogDrain ApplyProjectAddonBodyAddonType = "log_drain" - ApplyProjectAddonBodyAddonTypePitr ApplyProjectAddonBodyAddonType = "pitr" -) - -// Valid indicates whether the value is a known member of the ApplyProjectAddonBodyAddonType enum. -func (e ApplyProjectAddonBodyAddonType) Valid() bool { - switch e { - case ApplyProjectAddonBodyAddonTypeAuthMfaPhone: - return true - case ApplyProjectAddonBodyAddonTypeAuthMfaWebAuthn: - return true - case ApplyProjectAddonBodyAddonTypeComputeInstance: - return true - case ApplyProjectAddonBodyAddonTypeCustomDomain: - return true - case ApplyProjectAddonBodyAddonTypeEtlPipeline: - return true - case ApplyProjectAddonBodyAddonTypeIpv4: - return true - case ApplyProjectAddonBodyAddonTypeLogDrain: - return true - case ApplyProjectAddonBodyAddonTypePitr: - return true - default: - return false - } -} - -// Defines values for ApplyProjectAddonBodyAddonVariant0. -const ( - ApplyProjectAddonBodyAddonVariant0Ci12xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_12xlarge" - ApplyProjectAddonBodyAddonVariant0Ci16xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_16xlarge" - ApplyProjectAddonBodyAddonVariant0Ci24xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_24xlarge" - ApplyProjectAddonBodyAddonVariant0Ci24xlargeHighMemory ApplyProjectAddonBodyAddonVariant0 = "ci_24xlarge_high_memory" - ApplyProjectAddonBodyAddonVariant0Ci24xlargeOptimizedCpu ApplyProjectAddonBodyAddonVariant0 = "ci_24xlarge_optimized_cpu" - ApplyProjectAddonBodyAddonVariant0Ci24xlargeOptimizedMemory ApplyProjectAddonBodyAddonVariant0 = "ci_24xlarge_optimized_memory" - ApplyProjectAddonBodyAddonVariant0Ci2xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_2xlarge" - ApplyProjectAddonBodyAddonVariant0Ci48xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_48xlarge" - ApplyProjectAddonBodyAddonVariant0Ci48xlargeHighMemory ApplyProjectAddonBodyAddonVariant0 = "ci_48xlarge_high_memory" - ApplyProjectAddonBodyAddonVariant0Ci48xlargeOptimizedCpu ApplyProjectAddonBodyAddonVariant0 = "ci_48xlarge_optimized_cpu" - ApplyProjectAddonBodyAddonVariant0Ci48xlargeOptimizedMemory ApplyProjectAddonBodyAddonVariant0 = "ci_48xlarge_optimized_memory" - ApplyProjectAddonBodyAddonVariant0Ci4xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_4xlarge" - ApplyProjectAddonBodyAddonVariant0Ci8xlarge ApplyProjectAddonBodyAddonVariant0 = "ci_8xlarge" - ApplyProjectAddonBodyAddonVariant0CiLarge ApplyProjectAddonBodyAddonVariant0 = "ci_large" - ApplyProjectAddonBodyAddonVariant0CiMedium ApplyProjectAddonBodyAddonVariant0 = "ci_medium" - ApplyProjectAddonBodyAddonVariant0CiMicro ApplyProjectAddonBodyAddonVariant0 = "ci_micro" - ApplyProjectAddonBodyAddonVariant0CiSmall ApplyProjectAddonBodyAddonVariant0 = "ci_small" - ApplyProjectAddonBodyAddonVariant0CiXlarge ApplyProjectAddonBodyAddonVariant0 = "ci_xlarge" -) - -// Valid indicates whether the value is a known member of the ApplyProjectAddonBodyAddonVariant0 enum. -func (e ApplyProjectAddonBodyAddonVariant0) Valid() bool { - switch e { - case ApplyProjectAddonBodyAddonVariant0Ci12xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci16xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci24xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci24xlargeHighMemory: - return true - case ApplyProjectAddonBodyAddonVariant0Ci24xlargeOptimizedCpu: - return true - case ApplyProjectAddonBodyAddonVariant0Ci24xlargeOptimizedMemory: - return true - case ApplyProjectAddonBodyAddonVariant0Ci2xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci48xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci48xlargeHighMemory: - return true - case ApplyProjectAddonBodyAddonVariant0Ci48xlargeOptimizedCpu: - return true - case ApplyProjectAddonBodyAddonVariant0Ci48xlargeOptimizedMemory: - return true - case ApplyProjectAddonBodyAddonVariant0Ci4xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0Ci8xlarge: - return true - case ApplyProjectAddonBodyAddonVariant0CiLarge: - return true - case ApplyProjectAddonBodyAddonVariant0CiMedium: - return true - case ApplyProjectAddonBodyAddonVariant0CiMicro: - return true - case ApplyProjectAddonBodyAddonVariant0CiSmall: - return true - case ApplyProjectAddonBodyAddonVariant0CiXlarge: - return true - default: - return false - } -} - -// Defines values for ApplyProjectAddonBodyAddonVariant1. -const ( - ApplyProjectAddonBodyAddonVariant1CdDefault ApplyProjectAddonBodyAddonVariant1 = "cd_default" -) - -// Valid indicates whether the value is a known member of the ApplyProjectAddonBodyAddonVariant1 enum. -func (e ApplyProjectAddonBodyAddonVariant1) Valid() bool { - switch e { - case ApplyProjectAddonBodyAddonVariant1CdDefault: - return true - default: - return false - } -} - -// Defines values for ApplyProjectAddonBodyAddonVariant2. -const ( - ApplyProjectAddonBodyAddonVariant2Pitr14 ApplyProjectAddonBodyAddonVariant2 = "pitr_14" - ApplyProjectAddonBodyAddonVariant2Pitr28 ApplyProjectAddonBodyAddonVariant2 = "pitr_28" - ApplyProjectAddonBodyAddonVariant2Pitr7 ApplyProjectAddonBodyAddonVariant2 = "pitr_7" -) - -// Valid indicates whether the value is a known member of the ApplyProjectAddonBodyAddonVariant2 enum. -func (e ApplyProjectAddonBodyAddonVariant2) Valid() bool { - switch e { - case ApplyProjectAddonBodyAddonVariant2Pitr14: - return true - case ApplyProjectAddonBodyAddonVariant2Pitr28: - return true - case ApplyProjectAddonBodyAddonVariant2Pitr7: - return true - default: - return false - } -} - -// Defines values for ApplyProjectAddonBodyAddonVariant3. -const ( - ApplyProjectAddonBodyAddonVariant3Ipv4Default ApplyProjectAddonBodyAddonVariant3 = "ipv4_default" -) - -// Valid indicates whether the value is a known member of the ApplyProjectAddonBodyAddonVariant3 enum. -func (e ApplyProjectAddonBodyAddonVariant3) Valid() bool { - switch e { - case ApplyProjectAddonBodyAddonVariant3Ipv4Default: - return true - default: - return false - } -} - -// Defines values for AuthConfigResponseOutputDbMaxPoolSizeUnit. -const ( - AuthConfigResponseOutputDbMaxPoolSizeUnitConnections AuthConfigResponseOutputDbMaxPoolSizeUnit = "connections" - AuthConfigResponseOutputDbMaxPoolSizeUnitLessThannil AuthConfigResponseOutputDbMaxPoolSizeUnit = "" - AuthConfigResponseOutputDbMaxPoolSizeUnitPercent AuthConfigResponseOutputDbMaxPoolSizeUnit = "percent" -) - -// Valid indicates whether the value is a known member of the AuthConfigResponseOutputDbMaxPoolSizeUnit enum. -func (e AuthConfigResponseOutputDbMaxPoolSizeUnit) Valid() bool { - switch e { - case AuthConfigResponseOutputDbMaxPoolSizeUnitConnections: - return true - case AuthConfigResponseOutputDbMaxPoolSizeUnitLessThannil: - return true - case AuthConfigResponseOutputDbMaxPoolSizeUnitPercent: - return true - default: - return false - } -} - -// Defines values for AuthConfigResponseOutputPasswordRequiredCharacters. -const ( - AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 AuthConfigResponseOutputPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" - AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891 AuthConfigResponseOutputPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" - AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892 AuthConfigResponseOutputPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~" - AuthConfigResponseOutputPasswordRequiredCharactersEmpty AuthConfigResponseOutputPasswordRequiredCharacters = "" -) - -// Valid indicates whether the value is a known member of the AuthConfigResponseOutputPasswordRequiredCharacters enum. -func (e AuthConfigResponseOutputPasswordRequiredCharacters) Valid() bool { - switch e { - case AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789: - return true - case AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891: - return true - case AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892: - return true - case AuthConfigResponseOutputPasswordRequiredCharactersEmpty: - return true - default: - return false - } -} - -// Defines values for AuthConfigResponseOutputSecurityCaptchaProvider. -const ( - AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha AuthConfigResponseOutputSecurityCaptchaProvider = "hcaptcha" - AuthConfigResponseOutputSecurityCaptchaProviderLessThannil AuthConfigResponseOutputSecurityCaptchaProvider = "" - AuthConfigResponseOutputSecurityCaptchaProviderTurnstile AuthConfigResponseOutputSecurityCaptchaProvider = "turnstile" -) - -// Valid indicates whether the value is a known member of the AuthConfigResponseOutputSecurityCaptchaProvider enum. -func (e AuthConfigResponseOutputSecurityCaptchaProvider) Valid() bool { - switch e { - case AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha: - return true - case AuthConfigResponseOutputSecurityCaptchaProviderLessThannil: - return true - case AuthConfigResponseOutputSecurityCaptchaProviderTurnstile: - return true - default: - return false - } -} - -// Defines values for AuthConfigResponseOutputSmsProvider. -const ( - AuthConfigResponseOutputSmsProviderLessThannil AuthConfigResponseOutputSmsProvider = "" - AuthConfigResponseOutputSmsProviderMessagebird AuthConfigResponseOutputSmsProvider = "messagebird" - AuthConfigResponseOutputSmsProviderTextlocal AuthConfigResponseOutputSmsProvider = "textlocal" - AuthConfigResponseOutputSmsProviderTwilio AuthConfigResponseOutputSmsProvider = "twilio" - AuthConfigResponseOutputSmsProviderTwilioVerify AuthConfigResponseOutputSmsProvider = "twilio_verify" - AuthConfigResponseOutputSmsProviderVonage AuthConfigResponseOutputSmsProvider = "vonage" -) - -// Valid indicates whether the value is a known member of the AuthConfigResponseOutputSmsProvider enum. -func (e AuthConfigResponseOutputSmsProvider) Valid() bool { - switch e { - case AuthConfigResponseOutputSmsProviderLessThannil: - return true - case AuthConfigResponseOutputSmsProviderMessagebird: - return true - case AuthConfigResponseOutputSmsProviderTextlocal: - return true - case AuthConfigResponseOutputSmsProviderTwilio: - return true - case AuthConfigResponseOutputSmsProviderTwilioVerify: - return true - case AuthConfigResponseOutputSmsProviderVonage: - return true - default: - return false - } -} - -// Defines values for BranchDeleteResponseOutputMessage. -const ( - BranchDeleteResponseOutputMessageOk BranchDeleteResponseOutputMessage = "ok" -) - -// Valid indicates whether the value is a known member of the BranchDeleteResponseOutputMessage enum. -func (e BranchDeleteResponseOutputMessage) Valid() bool { - switch e { - case BranchDeleteResponseOutputMessageOk: - return true - default: - return false - } -} - -// Defines values for BranchDetailResponseOutputStatus. -const ( - BranchDetailResponseOutputStatusACTIVEHEALTHY BranchDetailResponseOutputStatus = "ACTIVE_HEALTHY" - BranchDetailResponseOutputStatusACTIVEUNHEALTHY BranchDetailResponseOutputStatus = "ACTIVE_UNHEALTHY" - BranchDetailResponseOutputStatusCOMINGUP BranchDetailResponseOutputStatus = "COMING_UP" - BranchDetailResponseOutputStatusGOINGDOWN BranchDetailResponseOutputStatus = "GOING_DOWN" - BranchDetailResponseOutputStatusINACTIVE BranchDetailResponseOutputStatus = "INACTIVE" - BranchDetailResponseOutputStatusINITFAILED BranchDetailResponseOutputStatus = "INIT_FAILED" - BranchDetailResponseOutputStatusPAUSEFAILED BranchDetailResponseOutputStatus = "PAUSE_FAILED" - BranchDetailResponseOutputStatusPAUSING BranchDetailResponseOutputStatus = "PAUSING" - BranchDetailResponseOutputStatusREMOVED BranchDetailResponseOutputStatus = "REMOVED" - BranchDetailResponseOutputStatusRESIZING BranchDetailResponseOutputStatus = "RESIZING" - BranchDetailResponseOutputStatusRESTARTING BranchDetailResponseOutputStatus = "RESTARTING" - BranchDetailResponseOutputStatusRESTOREFAILED BranchDetailResponseOutputStatus = "RESTORE_FAILED" - BranchDetailResponseOutputStatusRESTORING BranchDetailResponseOutputStatus = "RESTORING" - BranchDetailResponseOutputStatusUNKNOWN BranchDetailResponseOutputStatus = "UNKNOWN" - BranchDetailResponseOutputStatusUPGRADING BranchDetailResponseOutputStatus = "UPGRADING" -) - -// Valid indicates whether the value is a known member of the BranchDetailResponseOutputStatus enum. -func (e BranchDetailResponseOutputStatus) Valid() bool { - switch e { - case BranchDetailResponseOutputStatusACTIVEHEALTHY: - return true - case BranchDetailResponseOutputStatusACTIVEUNHEALTHY: - return true - case BranchDetailResponseOutputStatusCOMINGUP: - return true - case BranchDetailResponseOutputStatusGOINGDOWN: - return true - case BranchDetailResponseOutputStatusINACTIVE: - return true - case BranchDetailResponseOutputStatusINITFAILED: - return true - case BranchDetailResponseOutputStatusPAUSEFAILED: - return true - case BranchDetailResponseOutputStatusPAUSING: - return true - case BranchDetailResponseOutputStatusREMOVED: - return true - case BranchDetailResponseOutputStatusRESIZING: - return true - case BranchDetailResponseOutputStatusRESTARTING: - return true - case BranchDetailResponseOutputStatusRESTOREFAILED: - return true - case BranchDetailResponseOutputStatusRESTORING: - return true - case BranchDetailResponseOutputStatusUNKNOWN: - return true - case BranchDetailResponseOutputStatusUPGRADING: - return true - default: - return false - } -} - -// Defines values for BranchResponseOutputPreviewProjectStatus. -const ( - BranchResponseOutputPreviewProjectStatusACTIVEHEALTHY BranchResponseOutputPreviewProjectStatus = "ACTIVE_HEALTHY" - BranchResponseOutputPreviewProjectStatusACTIVEUNHEALTHY BranchResponseOutputPreviewProjectStatus = "ACTIVE_UNHEALTHY" - BranchResponseOutputPreviewProjectStatusCOMINGUP BranchResponseOutputPreviewProjectStatus = "COMING_UP" - BranchResponseOutputPreviewProjectStatusGOINGDOWN BranchResponseOutputPreviewProjectStatus = "GOING_DOWN" - BranchResponseOutputPreviewProjectStatusINACTIVE BranchResponseOutputPreviewProjectStatus = "INACTIVE" - BranchResponseOutputPreviewProjectStatusINITFAILED BranchResponseOutputPreviewProjectStatus = "INIT_FAILED" - BranchResponseOutputPreviewProjectStatusPAUSEFAILED BranchResponseOutputPreviewProjectStatus = "PAUSE_FAILED" - BranchResponseOutputPreviewProjectStatusPAUSING BranchResponseOutputPreviewProjectStatus = "PAUSING" - BranchResponseOutputPreviewProjectStatusREMOVED BranchResponseOutputPreviewProjectStatus = "REMOVED" - BranchResponseOutputPreviewProjectStatusRESIZING BranchResponseOutputPreviewProjectStatus = "RESIZING" - BranchResponseOutputPreviewProjectStatusRESTARTING BranchResponseOutputPreviewProjectStatus = "RESTARTING" - BranchResponseOutputPreviewProjectStatusRESTOREFAILED BranchResponseOutputPreviewProjectStatus = "RESTORE_FAILED" - BranchResponseOutputPreviewProjectStatusRESTORING BranchResponseOutputPreviewProjectStatus = "RESTORING" - BranchResponseOutputPreviewProjectStatusUNKNOWN BranchResponseOutputPreviewProjectStatus = "UNKNOWN" - BranchResponseOutputPreviewProjectStatusUPGRADING BranchResponseOutputPreviewProjectStatus = "UPGRADING" -) - -// Valid indicates whether the value is a known member of the BranchResponseOutputPreviewProjectStatus enum. -func (e BranchResponseOutputPreviewProjectStatus) Valid() bool { - switch e { - case BranchResponseOutputPreviewProjectStatusACTIVEHEALTHY: - return true - case BranchResponseOutputPreviewProjectStatusACTIVEUNHEALTHY: - return true - case BranchResponseOutputPreviewProjectStatusCOMINGUP: - return true - case BranchResponseOutputPreviewProjectStatusGOINGDOWN: - return true - case BranchResponseOutputPreviewProjectStatusINACTIVE: - return true - case BranchResponseOutputPreviewProjectStatusINITFAILED: - return true - case BranchResponseOutputPreviewProjectStatusPAUSEFAILED: - return true - case BranchResponseOutputPreviewProjectStatusPAUSING: - return true - case BranchResponseOutputPreviewProjectStatusREMOVED: - return true - case BranchResponseOutputPreviewProjectStatusRESIZING: - return true - case BranchResponseOutputPreviewProjectStatusRESTARTING: - return true - case BranchResponseOutputPreviewProjectStatusRESTOREFAILED: - return true - case BranchResponseOutputPreviewProjectStatusRESTORING: - return true - case BranchResponseOutputPreviewProjectStatusUNKNOWN: - return true - case BranchResponseOutputPreviewProjectStatusUPGRADING: - return true - default: - return false - } -} - -// Defines values for BranchResponseOutputStatus. -const ( - BranchResponseOutputStatusCREATINGPROJECT BranchResponseOutputStatus = "CREATING_PROJECT" - BranchResponseOutputStatusFUNCTIONSDEPLOYED BranchResponseOutputStatus = "FUNCTIONS_DEPLOYED" - BranchResponseOutputStatusFUNCTIONSFAILED BranchResponseOutputStatus = "FUNCTIONS_FAILED" - BranchResponseOutputStatusMIGRATIONSFAILED BranchResponseOutputStatus = "MIGRATIONS_FAILED" - BranchResponseOutputStatusMIGRATIONSPASSED BranchResponseOutputStatus = "MIGRATIONS_PASSED" - BranchResponseOutputStatusRUNNINGMIGRATIONS BranchResponseOutputStatus = "RUNNING_MIGRATIONS" -) - -// Valid indicates whether the value is a known member of the BranchResponseOutputStatus enum. -func (e BranchResponseOutputStatus) Valid() bool { - switch e { - case BranchResponseOutputStatusCREATINGPROJECT: - return true - case BranchResponseOutputStatusFUNCTIONSDEPLOYED: - return true - case BranchResponseOutputStatusFUNCTIONSFAILED: - return true - case BranchResponseOutputStatusMIGRATIONSFAILED: - return true - case BranchResponseOutputStatusMIGRATIONSPASSED: - return true - case BranchResponseOutputStatusRUNNINGMIGRATIONS: - return true - default: - return false - } -} - -// Defines values for BranchRestoreResponseOutputMessage. -const ( - BranchRestorationInitiated BranchRestoreResponseOutputMessage = "Branch restoration initiated" -) - -// Valid indicates whether the value is a known member of the BranchRestoreResponseOutputMessage enum. -func (e BranchRestoreResponseOutputMessage) Valid() bool { - switch e { - case BranchRestorationInitiated: - return true - default: - return false - } -} - -// Defines values for BranchUpdateResponseOutputMessage. -const ( - BranchUpdateResponseOutputMessageOk BranchUpdateResponseOutputMessage = "ok" -) - -// Valid indicates whether the value is a known member of the BranchUpdateResponseOutputMessage enum. -func (e BranchUpdateResponseOutputMessage) Valid() bool { - switch e { - case BranchUpdateResponseOutputMessageOk: - return true - default: - return false - } -} - -// Defines values for BulkUpdateFunctionBodyStatus. -const ( - BulkUpdateFunctionBodyStatusACTIVE BulkUpdateFunctionBodyStatus = "ACTIVE" - BulkUpdateFunctionBodyStatusREMOVED BulkUpdateFunctionBodyStatus = "REMOVED" - BulkUpdateFunctionBodyStatusTHROTTLED BulkUpdateFunctionBodyStatus = "THROTTLED" -) - -// Valid indicates whether the value is a known member of the BulkUpdateFunctionBodyStatus enum. -func (e BulkUpdateFunctionBodyStatus) Valid() bool { - switch e { - case BulkUpdateFunctionBodyStatusACTIVE: - return true - case BulkUpdateFunctionBodyStatusREMOVED: - return true - case BulkUpdateFunctionBodyStatusTHROTTLED: - return true - default: - return false - } -} - -// Defines values for BulkUpdateFunctionResponseOutputFunctionsStatus. -const ( - BulkUpdateFunctionResponseOutputFunctionsStatusACTIVE BulkUpdateFunctionResponseOutputFunctionsStatus = "ACTIVE" - BulkUpdateFunctionResponseOutputFunctionsStatusREMOVED BulkUpdateFunctionResponseOutputFunctionsStatus = "REMOVED" - BulkUpdateFunctionResponseOutputFunctionsStatusTHROTTLED BulkUpdateFunctionResponseOutputFunctionsStatus = "THROTTLED" -) - -// Valid indicates whether the value is a known member of the BulkUpdateFunctionResponseOutputFunctionsStatus enum. -func (e BulkUpdateFunctionResponseOutputFunctionsStatus) Valid() bool { - switch e { - case BulkUpdateFunctionResponseOutputFunctionsStatusACTIVE: - return true - case BulkUpdateFunctionResponseOutputFunctionsStatusREMOVED: - return true - case BulkUpdateFunctionResponseOutputFunctionsStatusTHROTTLED: - return true - default: - return false - } -} - -// Defines values for CreateApiKeyBodyType. -const ( - Publishable CreateApiKeyBodyType = "publishable" - Secret CreateApiKeyBodyType = "secret" -) - -// Valid indicates whether the value is a known member of the CreateApiKeyBodyType enum. -func (e CreateApiKeyBodyType) Valid() bool { - switch e { - case Publishable: - return true - case Secret: - return true - default: - return false - } -} - -// Defines values for CreateBranchBodyDesiredInstanceSize. -const ( - CreateBranchBodyDesiredInstanceSizeLarge CreateBranchBodyDesiredInstanceSize = "large" - CreateBranchBodyDesiredInstanceSizeMedium CreateBranchBodyDesiredInstanceSize = "medium" - CreateBranchBodyDesiredInstanceSizeMicro CreateBranchBodyDesiredInstanceSize = "micro" - CreateBranchBodyDesiredInstanceSizeN12xlarge CreateBranchBodyDesiredInstanceSize = "12xlarge" - CreateBranchBodyDesiredInstanceSizeN16xlarge CreateBranchBodyDesiredInstanceSize = "16xlarge" - CreateBranchBodyDesiredInstanceSizeN24xlarge CreateBranchBodyDesiredInstanceSize = "24xlarge" - CreateBranchBodyDesiredInstanceSizeN24xlargeHighMemory CreateBranchBodyDesiredInstanceSize = "24xlarge_high_memory" - CreateBranchBodyDesiredInstanceSizeN24xlargeOptimizedCpu CreateBranchBodyDesiredInstanceSize = "24xlarge_optimized_cpu" - CreateBranchBodyDesiredInstanceSizeN24xlargeOptimizedMemory CreateBranchBodyDesiredInstanceSize = "24xlarge_optimized_memory" - CreateBranchBodyDesiredInstanceSizeN2xlarge CreateBranchBodyDesiredInstanceSize = "2xlarge" - CreateBranchBodyDesiredInstanceSizeN48xlarge CreateBranchBodyDesiredInstanceSize = "48xlarge" - CreateBranchBodyDesiredInstanceSizeN48xlargeHighMemory CreateBranchBodyDesiredInstanceSize = "48xlarge_high_memory" - CreateBranchBodyDesiredInstanceSizeN48xlargeOptimizedCpu CreateBranchBodyDesiredInstanceSize = "48xlarge_optimized_cpu" - CreateBranchBodyDesiredInstanceSizeN48xlargeOptimizedMemory CreateBranchBodyDesiredInstanceSize = "48xlarge_optimized_memory" - CreateBranchBodyDesiredInstanceSizeN4xlarge CreateBranchBodyDesiredInstanceSize = "4xlarge" - CreateBranchBodyDesiredInstanceSizeN8xlarge CreateBranchBodyDesiredInstanceSize = "8xlarge" - CreateBranchBodyDesiredInstanceSizeNano CreateBranchBodyDesiredInstanceSize = "nano" - CreateBranchBodyDesiredInstanceSizePico CreateBranchBodyDesiredInstanceSize = "pico" - CreateBranchBodyDesiredInstanceSizeSmall CreateBranchBodyDesiredInstanceSize = "small" - CreateBranchBodyDesiredInstanceSizeXlarge CreateBranchBodyDesiredInstanceSize = "xlarge" -) - -// Valid indicates whether the value is a known member of the CreateBranchBodyDesiredInstanceSize enum. -func (e CreateBranchBodyDesiredInstanceSize) Valid() bool { - switch e { - case CreateBranchBodyDesiredInstanceSizeLarge: - return true - case CreateBranchBodyDesiredInstanceSizeMedium: - return true - case CreateBranchBodyDesiredInstanceSizeMicro: - return true - case CreateBranchBodyDesiredInstanceSizeN12xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN16xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN24xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN24xlargeHighMemory: - return true - case CreateBranchBodyDesiredInstanceSizeN24xlargeOptimizedCpu: - return true - case CreateBranchBodyDesiredInstanceSizeN24xlargeOptimizedMemory: - return true - case CreateBranchBodyDesiredInstanceSizeN2xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN48xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN48xlargeHighMemory: - return true - case CreateBranchBodyDesiredInstanceSizeN48xlargeOptimizedCpu: - return true - case CreateBranchBodyDesiredInstanceSizeN48xlargeOptimizedMemory: - return true - case CreateBranchBodyDesiredInstanceSizeN4xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeN8xlarge: - return true - case CreateBranchBodyDesiredInstanceSizeNano: - return true - case CreateBranchBodyDesiredInstanceSizePico: - return true - case CreateBranchBodyDesiredInstanceSizeSmall: - return true - case CreateBranchBodyDesiredInstanceSizeXlarge: - return true - default: - return false - } -} - -// Defines values for CreateBranchBodyPostgresEngine. -const ( - CreateBranchBodyPostgresEngineN15 CreateBranchBodyPostgresEngine = "15" - CreateBranchBodyPostgresEngineN17 CreateBranchBodyPostgresEngine = "17" - CreateBranchBodyPostgresEngineN17Oriole CreateBranchBodyPostgresEngine = "17-oriole" -) - -// Valid indicates whether the value is a known member of the CreateBranchBodyPostgresEngine enum. -func (e CreateBranchBodyPostgresEngine) Valid() bool { - switch e { - case CreateBranchBodyPostgresEngineN15: - return true - case CreateBranchBodyPostgresEngineN17: - return true - case CreateBranchBodyPostgresEngineN17Oriole: - return true - default: - return false - } -} - -// Defines values for CreateBranchBodyReleaseChannel. -const ( - CreateBranchBodyReleaseChannelAlpha CreateBranchBodyReleaseChannel = "alpha" - CreateBranchBodyReleaseChannelBeta CreateBranchBodyReleaseChannel = "beta" - CreateBranchBodyReleaseChannelGa CreateBranchBodyReleaseChannel = "ga" - CreateBranchBodyReleaseChannelInternal CreateBranchBodyReleaseChannel = "internal" - CreateBranchBodyReleaseChannelPreview CreateBranchBodyReleaseChannel = "preview" - CreateBranchBodyReleaseChannelWithdrawn CreateBranchBodyReleaseChannel = "withdrawn" -) - -// Valid indicates whether the value is a known member of the CreateBranchBodyReleaseChannel enum. -func (e CreateBranchBodyReleaseChannel) Valid() bool { - switch e { - case CreateBranchBodyReleaseChannelAlpha: - return true - case CreateBranchBodyReleaseChannelBeta: - return true - case CreateBranchBodyReleaseChannelGa: - return true - case CreateBranchBodyReleaseChannelInternal: - return true - case CreateBranchBodyReleaseChannelPreview: - return true - case CreateBranchBodyReleaseChannelWithdrawn: - return true - default: - return false - } -} - -// Defines values for CreateProviderBodyNameIdFormat. -const ( - CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatEmailAddress CreateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" - CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatUnspecified CreateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" - CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatPersistent CreateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatTransient CreateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" -) - -// Valid indicates whether the value is a known member of the CreateProviderBodyNameIdFormat enum. -func (e CreateProviderBodyNameIdFormat) Valid() bool { - switch e { - case CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatEmailAddress: - return true - case CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatUnspecified: - return true - case CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatPersistent: - return true - case CreateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatTransient: - return true - default: - return false - } -} - -// Defines values for CreateProviderBodyType. -const ( - Saml CreateProviderBodyType = "saml" -) - -// Valid indicates whether the value is a known member of the CreateProviderBodyType enum. -func (e CreateProviderBodyType) Valid() bool { - switch e { - case Saml: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyAlgorithm. -const ( - CreateSigningKeyBodyAlgorithmES256 CreateSigningKeyBodyAlgorithm = "ES256" - CreateSigningKeyBodyAlgorithmEdDSA CreateSigningKeyBodyAlgorithm = "EdDSA" - CreateSigningKeyBodyAlgorithmHS256 CreateSigningKeyBodyAlgorithm = "HS256" - CreateSigningKeyBodyAlgorithmRS256 CreateSigningKeyBodyAlgorithm = "RS256" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyAlgorithm enum. -func (e CreateSigningKeyBodyAlgorithm) Valid() bool { - switch e { - case CreateSigningKeyBodyAlgorithmES256: - return true - case CreateSigningKeyBodyAlgorithmEdDSA: - return true - case CreateSigningKeyBodyAlgorithmHS256: - return true - case CreateSigningKeyBodyAlgorithmRS256: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0Alg. -const ( - CreateSigningKeyBodyPrivateJwk0AlgRS256 CreateSigningKeyBodyPrivateJwk0Alg = "RS256" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0Alg enum. -func (e CreateSigningKeyBodyPrivateJwk0Alg) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk0AlgRS256: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0E. -const ( - AQAB CreateSigningKeyBodyPrivateJwk0E = "AQAB" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0E enum. -func (e CreateSigningKeyBodyPrivateJwk0E) Valid() bool { - switch e { - case AQAB: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0Ext. -const ( - CreateSigningKeyBodyPrivateJwk0ExtTrue CreateSigningKeyBodyPrivateJwk0Ext = true -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0Ext enum. -func (e CreateSigningKeyBodyPrivateJwk0Ext) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk0ExtTrue: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0KeyOps. -const ( - CreateSigningKeyBodyPrivateJwk0KeyOpsSign CreateSigningKeyBodyPrivateJwk0KeyOps = "sign" - CreateSigningKeyBodyPrivateJwk0KeyOpsVerify CreateSigningKeyBodyPrivateJwk0KeyOps = "verify" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0KeyOps enum. -func (e CreateSigningKeyBodyPrivateJwk0KeyOps) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk0KeyOpsSign: - return true - case CreateSigningKeyBodyPrivateJwk0KeyOpsVerify: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0Kty. -const ( - RSA CreateSigningKeyBodyPrivateJwk0Kty = "RSA" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0Kty enum. -func (e CreateSigningKeyBodyPrivateJwk0Kty) Valid() bool { - switch e { - case RSA: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk0Use. -const ( - CreateSigningKeyBodyPrivateJwk0UseSig CreateSigningKeyBodyPrivateJwk0Use = "sig" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk0Use enum. -func (e CreateSigningKeyBodyPrivateJwk0Use) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk0UseSig: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1Alg. -const ( - CreateSigningKeyBodyPrivateJwk1AlgES256 CreateSigningKeyBodyPrivateJwk1Alg = "ES256" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1Alg enum. -func (e CreateSigningKeyBodyPrivateJwk1Alg) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk1AlgES256: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1Crv. -const ( - P256 CreateSigningKeyBodyPrivateJwk1Crv = "P-256" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1Crv enum. -func (e CreateSigningKeyBodyPrivateJwk1Crv) Valid() bool { - switch e { - case P256: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1Ext. -const ( - CreateSigningKeyBodyPrivateJwk1ExtTrue CreateSigningKeyBodyPrivateJwk1Ext = true -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1Ext enum. -func (e CreateSigningKeyBodyPrivateJwk1Ext) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk1ExtTrue: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1KeyOps. -const ( - CreateSigningKeyBodyPrivateJwk1KeyOpsSign CreateSigningKeyBodyPrivateJwk1KeyOps = "sign" - CreateSigningKeyBodyPrivateJwk1KeyOpsVerify CreateSigningKeyBodyPrivateJwk1KeyOps = "verify" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1KeyOps enum. -func (e CreateSigningKeyBodyPrivateJwk1KeyOps) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk1KeyOpsSign: - return true - case CreateSigningKeyBodyPrivateJwk1KeyOpsVerify: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1Kty. -const ( - EC CreateSigningKeyBodyPrivateJwk1Kty = "EC" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1Kty enum. -func (e CreateSigningKeyBodyPrivateJwk1Kty) Valid() bool { - switch e { - case EC: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk1Use. -const ( - CreateSigningKeyBodyPrivateJwk1UseSig CreateSigningKeyBodyPrivateJwk1Use = "sig" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk1Use enum. -func (e CreateSigningKeyBodyPrivateJwk1Use) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk1UseSig: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2Alg. -const ( - CreateSigningKeyBodyPrivateJwk2AlgEdDSA CreateSigningKeyBodyPrivateJwk2Alg = "EdDSA" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2Alg enum. -func (e CreateSigningKeyBodyPrivateJwk2Alg) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk2AlgEdDSA: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2Crv. -const ( - Ed25519 CreateSigningKeyBodyPrivateJwk2Crv = "Ed25519" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2Crv enum. -func (e CreateSigningKeyBodyPrivateJwk2Crv) Valid() bool { - switch e { - case Ed25519: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2Ext. -const ( - CreateSigningKeyBodyPrivateJwk2ExtTrue CreateSigningKeyBodyPrivateJwk2Ext = true -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2Ext enum. -func (e CreateSigningKeyBodyPrivateJwk2Ext) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk2ExtTrue: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2KeyOps. -const ( - CreateSigningKeyBodyPrivateJwk2KeyOpsSign CreateSigningKeyBodyPrivateJwk2KeyOps = "sign" - CreateSigningKeyBodyPrivateJwk2KeyOpsVerify CreateSigningKeyBodyPrivateJwk2KeyOps = "verify" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2KeyOps enum. -func (e CreateSigningKeyBodyPrivateJwk2KeyOps) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk2KeyOpsSign: - return true - case CreateSigningKeyBodyPrivateJwk2KeyOpsVerify: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2Kty. -const ( - OKP CreateSigningKeyBodyPrivateJwk2Kty = "OKP" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2Kty enum. -func (e CreateSigningKeyBodyPrivateJwk2Kty) Valid() bool { - switch e { - case OKP: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk2Use. -const ( - CreateSigningKeyBodyPrivateJwk2UseSig CreateSigningKeyBodyPrivateJwk2Use = "sig" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk2Use enum. -func (e CreateSigningKeyBodyPrivateJwk2Use) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk2UseSig: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk3Alg. -const ( - HS256 CreateSigningKeyBodyPrivateJwk3Alg = "HS256" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk3Alg enum. -func (e CreateSigningKeyBodyPrivateJwk3Alg) Valid() bool { - switch e { - case HS256: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk3Ext. -const ( - CreateSigningKeyBodyPrivateJwk3ExtTrue CreateSigningKeyBodyPrivateJwk3Ext = true -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk3Ext enum. -func (e CreateSigningKeyBodyPrivateJwk3Ext) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk3ExtTrue: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk3KeyOps. -const ( - CreateSigningKeyBodyPrivateJwk3KeyOpsSign CreateSigningKeyBodyPrivateJwk3KeyOps = "sign" - CreateSigningKeyBodyPrivateJwk3KeyOpsVerify CreateSigningKeyBodyPrivateJwk3KeyOps = "verify" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk3KeyOps enum. -func (e CreateSigningKeyBodyPrivateJwk3KeyOps) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk3KeyOpsSign: - return true - case CreateSigningKeyBodyPrivateJwk3KeyOpsVerify: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk3Kty. -const ( - Oct CreateSigningKeyBodyPrivateJwk3Kty = "oct" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk3Kty enum. -func (e CreateSigningKeyBodyPrivateJwk3Kty) Valid() bool { - switch e { - case Oct: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyPrivateJwk3Use. -const ( - CreateSigningKeyBodyPrivateJwk3UseSig CreateSigningKeyBodyPrivateJwk3Use = "sig" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyPrivateJwk3Use enum. -func (e CreateSigningKeyBodyPrivateJwk3Use) Valid() bool { - switch e { - case CreateSigningKeyBodyPrivateJwk3UseSig: - return true - default: - return false - } -} - -// Defines values for CreateSigningKeyBodyStatus. -const ( - CreateSigningKeyBodyStatusInUse CreateSigningKeyBodyStatus = "in_use" - CreateSigningKeyBodyStatusStandby CreateSigningKeyBodyStatus = "standby" -) - -// Valid indicates whether the value is a known member of the CreateSigningKeyBodyStatus enum. -func (e CreateSigningKeyBodyStatus) Valid() bool { - switch e { - case CreateSigningKeyBodyStatusInUse: - return true - case CreateSigningKeyBodyStatusStandby: - return true - default: - return false - } -} - -// Defines values for DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError. -const ( - N1UpgradedInstanceLaunchFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "1_upgraded_instance_launch_failed" - N2VolumeDetachchmentFromUpgradedInstanceFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "2_volume_detachchment_from_upgraded_instance_failed" - N3VolumeAttachmentToOriginalInstanceFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "3_volume_attachment_to_original_instance_failed" - N4DataUpgradeInitiationFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "4_data_upgrade_initiation_failed" - N5DataUpgradeCompletionFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "5_data_upgrade_completion_failed" - N6VolumeDetachchmentFromOriginalInstanceFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "6_volume_detachchment_from_original_instance_failed" - N7VolumeAttachmentToUpgradedInstanceFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "7_volume_attachment_to_upgraded_instance_failed" - N8UpgradeCompletionFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "8_upgrade_completion_failed" - N9PostPhysicalBackupFailed DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError = "9_post_physical_backup_failed" -) - -// Valid indicates whether the value is a known member of the DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError enum. -func (e DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError) Valid() bool { - switch e { - case N1UpgradedInstanceLaunchFailed: - return true - case N2VolumeDetachchmentFromUpgradedInstanceFailed: - return true - case N3VolumeAttachmentToOriginalInstanceFailed: - return true - case N4DataUpgradeInitiationFailed: - return true - case N5DataUpgradeCompletionFailed: - return true - case N6VolumeDetachchmentFromOriginalInstanceFailed: - return true - case N7VolumeAttachmentToUpgradedInstanceFailed: - return true - case N8UpgradeCompletionFailed: - return true - case N9PostPhysicalBackupFailed: - return true - default: - return false - } -} - -// Defines values for DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress. -const ( - N0Requested DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "0_requested" - N10CompletedPostPhysicalBackup DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "10_completed_post_physical_backup" - N1Started DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "1_started" - N2LaunchedUpgradedInstance DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "2_launched_upgraded_instance" - N3DetachedVolumeFromUpgradedInstance DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "3_detached_volume_from_upgraded_instance" - N4AttachedVolumeToOriginalInstance DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "4_attached_volume_to_original_instance" - N5InitiatedDataUpgrade DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "5_initiated_data_upgrade" - N6CompletedDataUpgrade DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "6_completed_data_upgrade" - N7DetachedVolumeFromOriginalInstance DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "7_detached_volume_from_original_instance" - N8AttachedVolumeToUpgradedInstance DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "8_attached_volume_to_upgraded_instance" - N9CompletedUpgrade DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress = "9_completed_upgrade" -) - -// Valid indicates whether the value is a known member of the DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress enum. -func (e DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress) Valid() bool { - switch e { - case N0Requested: - return true - case N10CompletedPostPhysicalBackup: - return true - case N1Started: - return true - case N2LaunchedUpgradedInstance: - return true - case N3DetachedVolumeFromUpgradedInstance: - return true - case N4AttachedVolumeToOriginalInstance: - return true - case N5InitiatedDataUpgrade: - return true - case N6CompletedDataUpgrade: - return true - case N7DetachedVolumeFromOriginalInstance: - return true - case N8AttachedVolumeToUpgradedInstance: - return true - case N9CompletedUpgrade: - return true - default: - return false - } -} - -// Defines values for DeleteRolesResponseOutputMessage. -const ( - DeleteRolesResponseOutputMessageOk DeleteRolesResponseOutputMessage = "ok" -) - -// Valid indicates whether the value is a known member of the DeleteRolesResponseOutputMessage enum. -func (e DeleteRolesResponseOutputMessage) Valid() bool { - switch e { - case DeleteRolesResponseOutputMessageOk: - return true - default: - return false - } -} - -// Defines values for DeployFunctionResponseOutputStatus. -const ( - DeployFunctionResponseOutputStatusACTIVE DeployFunctionResponseOutputStatus = "ACTIVE" - DeployFunctionResponseOutputStatusREMOVED DeployFunctionResponseOutputStatus = "REMOVED" - DeployFunctionResponseOutputStatusTHROTTLED DeployFunctionResponseOutputStatus = "THROTTLED" -) - -// Valid indicates whether the value is a known member of the DeployFunctionResponseOutputStatus enum. -func (e DeployFunctionResponseOutputStatus) Valid() bool { - switch e { - case DeployFunctionResponseOutputStatusACTIVE: - return true - case DeployFunctionResponseOutputStatusREMOVED: - return true - case DeployFunctionResponseOutputStatusTHROTTLED: - return true - default: - return false - } -} - -// Defines values for DiskRequestBodyAttributes0Type. -const ( - DiskRequestBodyAttributes0TypeGp3 DiskRequestBodyAttributes0Type = "gp3" -) - -// Valid indicates whether the value is a known member of the DiskRequestBodyAttributes0Type enum. -func (e DiskRequestBodyAttributes0Type) Valid() bool { - switch e { - case DiskRequestBodyAttributes0TypeGp3: - return true - default: - return false - } -} - -// Defines values for DiskRequestBodyAttributes1Type. -const ( - DiskRequestBodyAttributes1TypeIo2 DiskRequestBodyAttributes1Type = "io2" -) - -// Valid indicates whether the value is a known member of the DiskRequestBodyAttributes1Type enum. -func (e DiskRequestBodyAttributes1Type) Valid() bool { - switch e { - case DiskRequestBodyAttributes1TypeIo2: - return true - default: - return false - } -} - -// Defines values for DiskResponseOutputAttributes0Type. -const ( - DiskResponseOutputAttributes0TypeGp3 DiskResponseOutputAttributes0Type = "gp3" -) - -// Valid indicates whether the value is a known member of the DiskResponseOutputAttributes0Type enum. -func (e DiskResponseOutputAttributes0Type) Valid() bool { - switch e { - case DiskResponseOutputAttributes0TypeGp3: - return true - default: - return false - } -} - -// Defines values for DiskResponseOutputAttributes1Type. -const ( - DiskResponseOutputAttributes1TypeIo2 DiskResponseOutputAttributes1Type = "io2" -) - -// Valid indicates whether the value is a known member of the DiskResponseOutputAttributes1Type enum. -func (e DiskResponseOutputAttributes1Type) Valid() bool { - switch e { - case DiskResponseOutputAttributes1TypeIo2: - return true - default: - return false - } -} - -// Defines values for FunctionResponseOutputStatus. -const ( - FunctionResponseOutputStatusACTIVE FunctionResponseOutputStatus = "ACTIVE" - FunctionResponseOutputStatusREMOVED FunctionResponseOutputStatus = "REMOVED" - FunctionResponseOutputStatusTHROTTLED FunctionResponseOutputStatus = "THROTTLED" -) - -// Valid indicates whether the value is a known member of the FunctionResponseOutputStatus enum. -func (e FunctionResponseOutputStatus) Valid() bool { - switch e { - case FunctionResponseOutputStatusACTIVE: - return true - case FunctionResponseOutputStatusREMOVED: - return true - case FunctionResponseOutputStatusTHROTTLED: - return true - default: - return false - } -} - -// Defines values for FunctionSlugResponseOutputStatus. -const ( - FunctionSlugResponseOutputStatusACTIVE FunctionSlugResponseOutputStatus = "ACTIVE" - FunctionSlugResponseOutputStatusREMOVED FunctionSlugResponseOutputStatus = "REMOVED" - FunctionSlugResponseOutputStatusTHROTTLED FunctionSlugResponseOutputStatus = "THROTTLED" -) - -// Valid indicates whether the value is a known member of the FunctionSlugResponseOutputStatus enum. -func (e FunctionSlugResponseOutputStatus) Valid() bool { - switch e { - case FunctionSlugResponseOutputStatusACTIVE: - return true - case FunctionSlugResponseOutputStatusREMOVED: - return true - case FunctionSlugResponseOutputStatusTHROTTLED: - return true - default: - return false - } -} - -// Defines values for GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine. -const ( - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN13 GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine = "13" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN14 GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine = "14" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN15 GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine = "15" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN17 GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine = "17" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN17Oriole GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine = "17-oriole" -) - -// Valid indicates whether the value is a known member of the GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine enum. -func (e GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine) Valid() bool { - switch e { - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN13: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN14: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN15: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN17: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngineN17Oriole: - return true - default: - return false - } -} - -// Defines values for GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel. -const ( - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelAlpha GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "alpha" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelBeta GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "beta" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelGa GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "ga" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelInternal GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "internal" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelPreview GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "preview" - GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelWithdrawn GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel = "withdrawn" -) - -// Valid indicates whether the value is a known member of the GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel enum. -func (e GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel) Valid() bool { - switch e { - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelAlpha: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelBeta: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelGa: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelInternal: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelPreview: - return true - case GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannelWithdrawn: - return true - default: - return false - } -} - -// Defines values for JitAccessRequestRequestState. -const ( - JitAccessRequestRequestStateDisabled JitAccessRequestRequestState = "disabled" - JitAccessRequestRequestStateEnabled JitAccessRequestRequestState = "enabled" -) - -// Valid indicates whether the value is a known member of the JitAccessRequestRequestState enum. -func (e JitAccessRequestRequestState) Valid() bool { - switch e { - case JitAccessRequestRequestStateDisabled: - return true - case JitAccessRequestRequestStateEnabled: - return true - default: - return false - } -} - -// Defines values for ListActionRunResponseOutputRunStepsName. -const ( - ListActionRunResponseOutputRunStepsNameClone ListActionRunResponseOutputRunStepsName = "clone" - ListActionRunResponseOutputRunStepsNameConfigure ListActionRunResponseOutputRunStepsName = "configure" - ListActionRunResponseOutputRunStepsNameDeploy ListActionRunResponseOutputRunStepsName = "deploy" - ListActionRunResponseOutputRunStepsNameHealth ListActionRunResponseOutputRunStepsName = "health" - ListActionRunResponseOutputRunStepsNameMigrate ListActionRunResponseOutputRunStepsName = "migrate" - ListActionRunResponseOutputRunStepsNamePull ListActionRunResponseOutputRunStepsName = "pull" - ListActionRunResponseOutputRunStepsNameSeed ListActionRunResponseOutputRunStepsName = "seed" -) - -// Valid indicates whether the value is a known member of the ListActionRunResponseOutputRunStepsName enum. -func (e ListActionRunResponseOutputRunStepsName) Valid() bool { - switch e { - case ListActionRunResponseOutputRunStepsNameClone: - return true - case ListActionRunResponseOutputRunStepsNameConfigure: - return true - case ListActionRunResponseOutputRunStepsNameDeploy: - return true - case ListActionRunResponseOutputRunStepsNameHealth: - return true - case ListActionRunResponseOutputRunStepsNameMigrate: - return true - case ListActionRunResponseOutputRunStepsNamePull: - return true - case ListActionRunResponseOutputRunStepsNameSeed: - return true - default: - return false - } -} - -// Defines values for ListActionRunResponseOutputRunStepsStatus. -const ( - ListActionRunResponseOutputRunStepsStatusCREATED ListActionRunResponseOutputRunStepsStatus = "CREATED" - ListActionRunResponseOutputRunStepsStatusDEAD ListActionRunResponseOutputRunStepsStatus = "DEAD" - ListActionRunResponseOutputRunStepsStatusEXITED ListActionRunResponseOutputRunStepsStatus = "EXITED" - ListActionRunResponseOutputRunStepsStatusPAUSED ListActionRunResponseOutputRunStepsStatus = "PAUSED" - ListActionRunResponseOutputRunStepsStatusREMOVING ListActionRunResponseOutputRunStepsStatus = "REMOVING" - ListActionRunResponseOutputRunStepsStatusRESTARTING ListActionRunResponseOutputRunStepsStatus = "RESTARTING" - ListActionRunResponseOutputRunStepsStatusRUNNING ListActionRunResponseOutputRunStepsStatus = "RUNNING" -) - -// Valid indicates whether the value is a known member of the ListActionRunResponseOutputRunStepsStatus enum. -func (e ListActionRunResponseOutputRunStepsStatus) Valid() bool { - switch e { - case ListActionRunResponseOutputRunStepsStatusCREATED: - return true - case ListActionRunResponseOutputRunStepsStatusDEAD: - return true - case ListActionRunResponseOutputRunStepsStatusEXITED: - return true - case ListActionRunResponseOutputRunStepsStatusPAUSED: - return true - case ListActionRunResponseOutputRunStepsStatusREMOVING: - return true - case ListActionRunResponseOutputRunStepsStatusRESTARTING: - return true - case ListActionRunResponseOutputRunStepsStatusRUNNING: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsType. -const ( - ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaPhone ListProjectAddonsResponseOutputAvailableAddonsType = "auth_mfa_phone" - ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaWebAuthn ListProjectAddonsResponseOutputAvailableAddonsType = "auth_mfa_web_authn" - ListProjectAddonsResponseOutputAvailableAddonsTypeComputeInstance ListProjectAddonsResponseOutputAvailableAddonsType = "compute_instance" - ListProjectAddonsResponseOutputAvailableAddonsTypeCustomDomain ListProjectAddonsResponseOutputAvailableAddonsType = "custom_domain" - ListProjectAddonsResponseOutputAvailableAddonsTypeEtlPipeline ListProjectAddonsResponseOutputAvailableAddonsType = "etl_pipeline" - ListProjectAddonsResponseOutputAvailableAddonsTypeIpv4 ListProjectAddonsResponseOutputAvailableAddonsType = "ipv4" - ListProjectAddonsResponseOutputAvailableAddonsTypeLogDrain ListProjectAddonsResponseOutputAvailableAddonsType = "log_drain" - ListProjectAddonsResponseOutputAvailableAddonsTypePitr ListProjectAddonsResponseOutputAvailableAddonsType = "pitr" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsType enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsType) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaPhone: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaWebAuthn: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeComputeInstance: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeCustomDomain: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeEtlPipeline: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeIpv4: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypeLogDrain: - return true - case ListProjectAddonsResponseOutputAvailableAddonsTypePitr: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId0. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci12xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_12xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci16xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_16xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_24xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeHighMemory ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_24xlarge_high_memory" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeOptimizedCpu ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_24xlarge_optimized_cpu" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeOptimizedMemory ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_24xlarge_optimized_memory" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci2xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_2xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_48xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeHighMemory ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_48xlarge_high_memory" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeOptimizedCpu ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_48xlarge_optimized_cpu" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeOptimizedMemory ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_48xlarge_optimized_memory" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci4xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_4xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci8xlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_8xlarge" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiLarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_large" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiMedium ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_medium" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiMicro ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_micro" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiSmall ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_small" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiXlarge ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 = "ci_xlarge" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId0) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci12xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci16xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeHighMemory: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeOptimizedCpu: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci24xlargeOptimizedMemory: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci2xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeHighMemory: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeOptimizedCpu: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci48xlargeOptimizedMemory: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci4xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0Ci8xlarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiLarge: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiMedium: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiMicro: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiSmall: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId0CiXlarge: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId1. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId1CdDefault ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 = "cd_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId1) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId1CdDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId2. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr14 ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 = "pitr_14" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr28 ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 = "pitr_28" - ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr7 ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 = "pitr_7" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId2) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr14: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr28: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId2Pitr7: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId3. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId3Ipv4Default ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 = "ipv4_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId3) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId3Ipv4Default: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId4. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId4AuthMfaPhoneDefault ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 = "auth_mfa_phone_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId4) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId4AuthMfaPhoneDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId5. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId5AuthMfaWebAuthnDefault ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 = "auth_mfa_web_authn_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId5) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId5AuthMfaWebAuthnDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId6. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId6LogDrainDefault ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 = "log_drain_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId6) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId6LogDrainDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsId7. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsId7EtlPipelineDefault ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 = "etl_pipeline_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsId7) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsId7EtlPipelineDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceIntervalHourly ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval = "hourly" - ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceIntervalMonthly ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval = "monthly" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceIntervalHourly: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceIntervalMonthly: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType. -const ( - ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceTypeFixed ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType = "fixed" - ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceTypeUsage ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType = "usage" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType enum. -func (e ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceTypeFixed: - return true - case ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceTypeUsage: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsType. -const ( - ListProjectAddonsResponseOutputSelectedAddonsTypeAuthMfaPhone ListProjectAddonsResponseOutputSelectedAddonsType = "auth_mfa_phone" - ListProjectAddonsResponseOutputSelectedAddonsTypeAuthMfaWebAuthn ListProjectAddonsResponseOutputSelectedAddonsType = "auth_mfa_web_authn" - ListProjectAddonsResponseOutputSelectedAddonsTypeComputeInstance ListProjectAddonsResponseOutputSelectedAddonsType = "compute_instance" - ListProjectAddonsResponseOutputSelectedAddonsTypeCustomDomain ListProjectAddonsResponseOutputSelectedAddonsType = "custom_domain" - ListProjectAddonsResponseOutputSelectedAddonsTypeEtlPipeline ListProjectAddonsResponseOutputSelectedAddonsType = "etl_pipeline" - ListProjectAddonsResponseOutputSelectedAddonsTypeIpv4 ListProjectAddonsResponseOutputSelectedAddonsType = "ipv4" - ListProjectAddonsResponseOutputSelectedAddonsTypeLogDrain ListProjectAddonsResponseOutputSelectedAddonsType = "log_drain" - ListProjectAddonsResponseOutputSelectedAddonsTypePitr ListProjectAddonsResponseOutputSelectedAddonsType = "pitr" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsType enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsType) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsTypeAuthMfaPhone: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeAuthMfaWebAuthn: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeComputeInstance: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeCustomDomain: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeEtlPipeline: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeIpv4: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypeLogDrain: - return true - case ListProjectAddonsResponseOutputSelectedAddonsTypePitr: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId0. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci12xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_12xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci16xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_16xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_24xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeHighMemory ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_24xlarge_high_memory" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeOptimizedCpu ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_24xlarge_optimized_cpu" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeOptimizedMemory ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_24xlarge_optimized_memory" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci2xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_2xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_48xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeHighMemory ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_48xlarge_high_memory" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeOptimizedCpu ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_48xlarge_optimized_cpu" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeOptimizedMemory ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_48xlarge_optimized_memory" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci4xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_4xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci8xlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_8xlarge" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiLarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_large" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiMedium ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_medium" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiMicro ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_micro" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiSmall ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_small" - ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiXlarge ListProjectAddonsResponseOutputSelectedAddonsVariantId0 = "ci_xlarge" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId0 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId0) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci12xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci16xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeHighMemory: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeOptimizedCpu: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci24xlargeOptimizedMemory: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci2xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeHighMemory: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeOptimizedCpu: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci48xlargeOptimizedMemory: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci4xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0Ci8xlarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiLarge: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiMedium: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiMicro: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiSmall: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId0CiXlarge: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId1. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId1CdDefault ListProjectAddonsResponseOutputSelectedAddonsVariantId1 = "cd_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId1 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId1) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId1CdDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId2. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr14 ListProjectAddonsResponseOutputSelectedAddonsVariantId2 = "pitr_14" - ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr28 ListProjectAddonsResponseOutputSelectedAddonsVariantId2 = "pitr_28" - ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr7 ListProjectAddonsResponseOutputSelectedAddonsVariantId2 = "pitr_7" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId2 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId2) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr14: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr28: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantId2Pitr7: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId3. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId3Ipv4Default ListProjectAddonsResponseOutputSelectedAddonsVariantId3 = "ipv4_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId3 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId3) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId3Ipv4Default: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId4. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId4AuthMfaPhoneDefault ListProjectAddonsResponseOutputSelectedAddonsVariantId4 = "auth_mfa_phone_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId4 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId4) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId4AuthMfaPhoneDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId5. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId5AuthMfaWebAuthnDefault ListProjectAddonsResponseOutputSelectedAddonsVariantId5 = "auth_mfa_web_authn_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId5 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId5) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId5AuthMfaWebAuthnDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId6. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId6LogDrainDefault ListProjectAddonsResponseOutputSelectedAddonsVariantId6 = "log_drain_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId6 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId6) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId6LogDrainDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantId7. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantId7EtlPipelineDefault ListProjectAddonsResponseOutputSelectedAddonsVariantId7 = "etl_pipeline_default" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantId7 enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantId7) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantId7EtlPipelineDefault: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantPriceIntervalHourly ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval = "hourly" - ListProjectAddonsResponseOutputSelectedAddonsVariantPriceIntervalMonthly ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval = "monthly" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantPriceIntervalHourly: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantPriceIntervalMonthly: - return true - default: - return false - } -} - -// Defines values for ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType. -const ( - ListProjectAddonsResponseOutputSelectedAddonsVariantPriceTypeFixed ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType = "fixed" - ListProjectAddonsResponseOutputSelectedAddonsVariantPriceTypeUsage ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType = "usage" -) - -// Valid indicates whether the value is a known member of the ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType enum. -func (e ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType) Valid() bool { - switch e { - case ListProjectAddonsResponseOutputSelectedAddonsVariantPriceTypeFixed: - return true - case ListProjectAddonsResponseOutputSelectedAddonsVariantPriceTypeUsage: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsResponseOutputEntitlement. -const ( - NetworkRestrictionsResponseOutputEntitlementAllowed NetworkRestrictionsResponseOutputEntitlement = "allowed" - NetworkRestrictionsResponseOutputEntitlementDisallowed NetworkRestrictionsResponseOutputEntitlement = "disallowed" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsResponseOutputEntitlement enum. -func (e NetworkRestrictionsResponseOutputEntitlement) Valid() bool { - switch e { - case NetworkRestrictionsResponseOutputEntitlementAllowed: - return true - case NetworkRestrictionsResponseOutputEntitlementDisallowed: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsResponseOutputStatus. -const ( - NetworkRestrictionsResponseOutputStatusApplied NetworkRestrictionsResponseOutputStatus = "applied" - NetworkRestrictionsResponseOutputStatusStored NetworkRestrictionsResponseOutputStatus = "stored" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsResponseOutputStatus enum. -func (e NetworkRestrictionsResponseOutputStatus) Valid() bool { - switch e { - case NetworkRestrictionsResponseOutputStatusApplied: - return true - case NetworkRestrictionsResponseOutputStatusStored: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType. -const ( - NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsTypeV4 NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType = "v4" - NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsTypeV6 NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType = "v6" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType enum. -func (e NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType) Valid() bool { - switch e { - case NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsTypeV4: - return true - case NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsTypeV6: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsV2ResponseOutputEntitlement. -const ( - NetworkRestrictionsV2ResponseOutputEntitlementAllowed NetworkRestrictionsV2ResponseOutputEntitlement = "allowed" - NetworkRestrictionsV2ResponseOutputEntitlementDisallowed NetworkRestrictionsV2ResponseOutputEntitlement = "disallowed" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseOutputEntitlement enum. -func (e NetworkRestrictionsV2ResponseOutputEntitlement) Valid() bool { - switch e { - case NetworkRestrictionsV2ResponseOutputEntitlementAllowed: - return true - case NetworkRestrictionsV2ResponseOutputEntitlementDisallowed: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType. -const ( - NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsTypeV4 NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType = "v4" - NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsTypeV6 NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType = "v6" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType enum. -func (e NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType) Valid() bool { - switch e { - case NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsTypeV4: - return true - case NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsTypeV6: - return true - default: - return false - } -} - -// Defines values for NetworkRestrictionsV2ResponseOutputStatus. -const ( - NetworkRestrictionsV2ResponseOutputStatusApplied NetworkRestrictionsV2ResponseOutputStatus = "applied" - NetworkRestrictionsV2ResponseOutputStatusStored NetworkRestrictionsV2ResponseOutputStatus = "stored" -) - -// Valid indicates whether the value is a known member of the NetworkRestrictionsV2ResponseOutputStatus enum. -func (e NetworkRestrictionsV2ResponseOutputStatus) Valid() bool { - switch e { - case NetworkRestrictionsV2ResponseOutputStatusApplied: - return true - case NetworkRestrictionsV2ResponseOutputStatusStored: - return true - default: - return false - } -} - -// Defines values for OAuthTokenBodyGrantType. -const ( - AuthorizationCode OAuthTokenBodyGrantType = "authorization_code" - RefreshToken OAuthTokenBodyGrantType = "refresh_token" - UrnIetfParamsOauthGrantTypeJwtBearer OAuthTokenBodyGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" -) - -// Valid indicates whether the value is a known member of the OAuthTokenBodyGrantType enum. -func (e OAuthTokenBodyGrantType) Valid() bool { - switch e { - case AuthorizationCode: - return true - case RefreshToken: - return true - case UrnIetfParamsOauthGrantTypeJwtBearer: - return true - default: - return false - } -} - -// Defines values for OAuthTokenResponseOutputTokenType. -const ( - Bearer OAuthTokenResponseOutputTokenType = "Bearer" -) - -// Valid indicates whether the value is a known member of the OAuthTokenResponseOutputTokenType enum. -func (e OAuthTokenResponseOutputTokenType) Valid() bool { - switch e { - case Bearer: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan. -const ( - OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanEnterprise OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan = "enterprise" - OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanFree OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan = "free" - OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanPlatform OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan = "platform" - OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanPro OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan = "pro" - OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanTeam OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan = "team" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan enum. -func (e OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan) Valid() bool { - switch e { - case OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanEnterprise: - return true - case OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanFree: - return true - case OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanPlatform: - return true - case OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanPro: - return true - case OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlanTeam: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan. -const ( - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanEnterprise OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "enterprise" - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanFree OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "free" - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanLessThannil OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "" - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanPlatform OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "platform" - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanPro OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "pro" - OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanTeam OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan = "team" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan enum. -func (e OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan) Valid() bool { - switch e { - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanEnterprise: - return true - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanFree: - return true - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanLessThannil: - return true - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanPlatform: - return true - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanPro: - return true - case OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlanTeam: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectsResponseOutputProjectsDatabasesDiskType. -const ( - Gp3 OrganizationProjectsResponseOutputProjectsDatabasesDiskType = "gp3" - Io2 OrganizationProjectsResponseOutputProjectsDatabasesDiskType = "io2" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectsResponseOutputProjectsDatabasesDiskType enum. -func (e OrganizationProjectsResponseOutputProjectsDatabasesDiskType) Valid() bool { - switch e { - case Gp3: - return true - case Io2: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize. -const ( - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeLarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "large" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeMedium OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "medium" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeMicro OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "micro" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN12xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "12xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN16xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "16xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "24xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeHighMemory OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "24xlarge_high_memory" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeOptimizedCpu OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "24xlarge_optimized_cpu" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeOptimizedMemory OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "24xlarge_optimized_memory" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN2xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "2xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "48xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeHighMemory OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "48xlarge_high_memory" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeOptimizedCpu OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "48xlarge_optimized_cpu" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeOptimizedMemory OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "48xlarge_optimized_memory" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN4xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "4xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN8xlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "8xlarge" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeNano OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "nano" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizePico OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "pico" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeSmall OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "small" - OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeXlarge OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize = "xlarge" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize enum. -func (e OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize) Valid() bool { - switch e { - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeLarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeMedium: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeMicro: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN12xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN16xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeHighMemory: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeOptimizedCpu: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN24xlargeOptimizedMemory: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN2xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeHighMemory: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeOptimizedCpu: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN48xlargeOptimizedMemory: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN4xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeN8xlarge: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeNano: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizePico: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeSmall: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSizeXlarge: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectsResponseOutputProjectsDatabasesStatus. -const ( - OrganizationProjectsResponseOutputProjectsDatabasesStatusACTIVEHEALTHY OrganizationProjectsResponseOutputProjectsDatabasesStatus = "ACTIVE_HEALTHY" - OrganizationProjectsResponseOutputProjectsDatabasesStatusACTIVEUNHEALTHY OrganizationProjectsResponseOutputProjectsDatabasesStatus = "ACTIVE_UNHEALTHY" - OrganizationProjectsResponseOutputProjectsDatabasesStatusCOMINGUP OrganizationProjectsResponseOutputProjectsDatabasesStatus = "COMING_UP" - OrganizationProjectsResponseOutputProjectsDatabasesStatusGOINGDOWN OrganizationProjectsResponseOutputProjectsDatabasesStatus = "GOING_DOWN" - OrganizationProjectsResponseOutputProjectsDatabasesStatusINITFAILED OrganizationProjectsResponseOutputProjectsDatabasesStatus = "INIT_FAILED" - OrganizationProjectsResponseOutputProjectsDatabasesStatusINITREADREPLICA OrganizationProjectsResponseOutputProjectsDatabasesStatus = "INIT_READ_REPLICA" - OrganizationProjectsResponseOutputProjectsDatabasesStatusINITREADREPLICAFAILED OrganizationProjectsResponseOutputProjectsDatabasesStatus = "INIT_READ_REPLICA_FAILED" - OrganizationProjectsResponseOutputProjectsDatabasesStatusREMOVED OrganizationProjectsResponseOutputProjectsDatabasesStatus = "REMOVED" - OrganizationProjectsResponseOutputProjectsDatabasesStatusRESIZING OrganizationProjectsResponseOutputProjectsDatabasesStatus = "RESIZING" - OrganizationProjectsResponseOutputProjectsDatabasesStatusRESTARTING OrganizationProjectsResponseOutputProjectsDatabasesStatus = "RESTARTING" - OrganizationProjectsResponseOutputProjectsDatabasesStatusRESTORING OrganizationProjectsResponseOutputProjectsDatabasesStatus = "RESTORING" - OrganizationProjectsResponseOutputProjectsDatabasesStatusUNKNOWN OrganizationProjectsResponseOutputProjectsDatabasesStatus = "UNKNOWN" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectsResponseOutputProjectsDatabasesStatus enum. -func (e OrganizationProjectsResponseOutputProjectsDatabasesStatus) Valid() bool { - switch e { - case OrganizationProjectsResponseOutputProjectsDatabasesStatusACTIVEHEALTHY: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusACTIVEUNHEALTHY: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusCOMINGUP: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusGOINGDOWN: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusINITFAILED: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusINITREADREPLICA: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusINITREADREPLICAFAILED: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusREMOVED: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusRESIZING: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusRESTARTING: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusRESTORING: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesStatusUNKNOWN: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectsResponseOutputProjectsDatabasesType. -const ( - OrganizationProjectsResponseOutputProjectsDatabasesTypePRIMARY OrganizationProjectsResponseOutputProjectsDatabasesType = "PRIMARY" - OrganizationProjectsResponseOutputProjectsDatabasesTypeREADREPLICA OrganizationProjectsResponseOutputProjectsDatabasesType = "READ_REPLICA" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectsResponseOutputProjectsDatabasesType enum. -func (e OrganizationProjectsResponseOutputProjectsDatabasesType) Valid() bool { - switch e { - case OrganizationProjectsResponseOutputProjectsDatabasesTypePRIMARY: - return true - case OrganizationProjectsResponseOutputProjectsDatabasesTypeREADREPLICA: - return true - default: - return false - } -} - -// Defines values for OrganizationProjectsResponseOutputProjectsStatus. -const ( - OrganizationProjectsResponseOutputProjectsStatusACTIVEHEALTHY OrganizationProjectsResponseOutputProjectsStatus = "ACTIVE_HEALTHY" - OrganizationProjectsResponseOutputProjectsStatusACTIVEUNHEALTHY OrganizationProjectsResponseOutputProjectsStatus = "ACTIVE_UNHEALTHY" - OrganizationProjectsResponseOutputProjectsStatusCOMINGUP OrganizationProjectsResponseOutputProjectsStatus = "COMING_UP" - OrganizationProjectsResponseOutputProjectsStatusGOINGDOWN OrganizationProjectsResponseOutputProjectsStatus = "GOING_DOWN" - OrganizationProjectsResponseOutputProjectsStatusINACTIVE OrganizationProjectsResponseOutputProjectsStatus = "INACTIVE" - OrganizationProjectsResponseOutputProjectsStatusINITFAILED OrganizationProjectsResponseOutputProjectsStatus = "INIT_FAILED" - OrganizationProjectsResponseOutputProjectsStatusPAUSEFAILED OrganizationProjectsResponseOutputProjectsStatus = "PAUSE_FAILED" - OrganizationProjectsResponseOutputProjectsStatusPAUSING OrganizationProjectsResponseOutputProjectsStatus = "PAUSING" - OrganizationProjectsResponseOutputProjectsStatusREMOVED OrganizationProjectsResponseOutputProjectsStatus = "REMOVED" - OrganizationProjectsResponseOutputProjectsStatusRESIZING OrganizationProjectsResponseOutputProjectsStatus = "RESIZING" - OrganizationProjectsResponseOutputProjectsStatusRESTARTING OrganizationProjectsResponseOutputProjectsStatus = "RESTARTING" - OrganizationProjectsResponseOutputProjectsStatusRESTOREFAILED OrganizationProjectsResponseOutputProjectsStatus = "RESTORE_FAILED" - OrganizationProjectsResponseOutputProjectsStatusRESTORING OrganizationProjectsResponseOutputProjectsStatus = "RESTORING" - OrganizationProjectsResponseOutputProjectsStatusUNKNOWN OrganizationProjectsResponseOutputProjectsStatus = "UNKNOWN" - OrganizationProjectsResponseOutputProjectsStatusUPGRADING OrganizationProjectsResponseOutputProjectsStatus = "UPGRADING" -) - -// Valid indicates whether the value is a known member of the OrganizationProjectsResponseOutputProjectsStatus enum. -func (e OrganizationProjectsResponseOutputProjectsStatus) Valid() bool { - switch e { - case OrganizationProjectsResponseOutputProjectsStatusACTIVEHEALTHY: - return true - case OrganizationProjectsResponseOutputProjectsStatusACTIVEUNHEALTHY: - return true - case OrganizationProjectsResponseOutputProjectsStatusCOMINGUP: - return true - case OrganizationProjectsResponseOutputProjectsStatusGOINGDOWN: - return true - case OrganizationProjectsResponseOutputProjectsStatusINACTIVE: - return true - case OrganizationProjectsResponseOutputProjectsStatusINITFAILED: - return true - case OrganizationProjectsResponseOutputProjectsStatusPAUSEFAILED: - return true - case OrganizationProjectsResponseOutputProjectsStatusPAUSING: - return true - case OrganizationProjectsResponseOutputProjectsStatusREMOVED: - return true - case OrganizationProjectsResponseOutputProjectsStatusRESIZING: - return true - case OrganizationProjectsResponseOutputProjectsStatusRESTARTING: - return true - case OrganizationProjectsResponseOutputProjectsStatusRESTOREFAILED: - return true - case OrganizationProjectsResponseOutputProjectsStatusRESTORING: - return true - case OrganizationProjectsResponseOutputProjectsStatusUNKNOWN: - return true - case OrganizationProjectsResponseOutputProjectsStatusUPGRADING: - return true - default: - return false - } -} - -// Defines values for PlanGateErrorBodyErrorCode. -const ( - EntitlementRequired PlanGateErrorBodyErrorCode = "entitlement_required" -) - -// Valid indicates whether the value is a known member of the PlanGateErrorBodyErrorCode enum. -func (e PlanGateErrorBodyErrorCode) Valid() bool { - switch e { - case EntitlementRequired: - return true - default: - return false - } -} - -// Defines values for PostgresConfigResponseOutputSessionReplicationRole. -const ( - PostgresConfigResponseOutputSessionReplicationRoleLocal PostgresConfigResponseOutputSessionReplicationRole = "local" - PostgresConfigResponseOutputSessionReplicationRoleOrigin PostgresConfigResponseOutputSessionReplicationRole = "origin" - PostgresConfigResponseOutputSessionReplicationRoleReplica PostgresConfigResponseOutputSessionReplicationRole = "replica" -) - -// Valid indicates whether the value is a known member of the PostgresConfigResponseOutputSessionReplicationRole enum. -func (e PostgresConfigResponseOutputSessionReplicationRole) Valid() bool { - switch e { - case PostgresConfigResponseOutputSessionReplicationRoleLocal: - return true - case PostgresConfigResponseOutputSessionReplicationRoleOrigin: - return true - case PostgresConfigResponseOutputSessionReplicationRoleReplica: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel. -const ( - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelAlpha ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "alpha" - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelBeta ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "beta" - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelGa ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "ga" - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelInternal ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "internal" - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelPreview ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "preview" - ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelWithdrawn ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel = "withdrawn" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel enum. -func (e ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel) Valid() bool { - switch e { - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelAlpha: - return true - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelBeta: - return true - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelGa: - return true - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelInternal: - return true - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelPreview: - return true - case ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannelWithdrawn: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion. -const ( - N13 ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion = "13" - N14 ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion = "14" - N15 ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion = "15" - N17 ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion = "17" - N17Oriole ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion = "17-oriole" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion enum. -func (e ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion) Valid() bool { - switch e { - case N13: - return true - case N14: - return true - case N15: - return true - case N17: - return true - case N17Oriole: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel. -const ( - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelAlpha ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "alpha" - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelBeta ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "beta" - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelGa ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "ga" - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelInternal ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "internal" - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelPreview ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "preview" - ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelWithdrawn ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel = "withdrawn" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel enum. -func (e ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel) Valid() bool { - switch e { - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelAlpha: - return true - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelBeta: - return true - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelGa: - return true - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelInternal: - return true - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelPreview: - return true - case ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannelWithdrawn: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors0Type. -const ( - ObjectsDependingOnPgCron ProjectUpgradeEligibilityResponseOutputValidationErrors0Type = "objects_depending_on_pg_cron" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors0Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors0Type) Valid() bool { - switch e { - case ObjectsDependingOnPgCron: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors1Type. -const ( - IndexesReferencingLlToEarth ProjectUpgradeEligibilityResponseOutputValidationErrors1Type = "indexes_referencing_ll_to_earth" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors1Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors1Type) Valid() bool { - switch e { - case IndexesReferencingLlToEarth: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors2Type. -const ( - FunctionUsingObsoleteLang ProjectUpgradeEligibilityResponseOutputValidationErrors2Type = "function_using_obsolete_lang" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors2Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors2Type) Valid() bool { - switch e { - case FunctionUsingObsoleteLang: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors3Type. -const ( - UnsupportedExtension ProjectUpgradeEligibilityResponseOutputValidationErrors3Type = "unsupported_extension" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors3Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors3Type) Valid() bool { - switch e { - case UnsupportedExtension: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors4Type. -const ( - UnsupportedFdwHandler ProjectUpgradeEligibilityResponseOutputValidationErrors4Type = "unsupported_fdw_handler" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors4Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors4Type) Valid() bool { - switch e { - case UnsupportedFdwHandler: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors5Type. -const ( - UnloggedTableWithPersistentSequence ProjectUpgradeEligibilityResponseOutputValidationErrors5Type = "unlogged_table_with_persistent_sequence" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors5Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors5Type) Valid() bool { - switch e { - case UnloggedTableWithPersistentSequence: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0. -const ( - ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0Table ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 = "table" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0) Valid() bool { - switch e { - case ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0Table: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1. -const ( - Function ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 = "function" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1) Valid() bool { - switch e { - case Function: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors6Type. -const ( - UserDefinedObjectsInInternalSchemas ProjectUpgradeEligibilityResponseOutputValidationErrors6Type = "user_defined_objects_in_internal_schemas" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors6Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors6Type) Valid() bool { - switch e { - case UserDefinedObjectsInInternalSchemas: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors7Type. -const ( - ActiveReplicationSlot ProjectUpgradeEligibilityResponseOutputValidationErrors7Type = "active_replication_slot" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors7Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors7Type) Valid() bool { - switch e { - case ActiveReplicationSlot: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors8Type. -const ( - X86Architecture ProjectUpgradeEligibilityResponseOutputValidationErrors8Type = "x86_architecture" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors8Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors8Type) Valid() bool { - switch e { - case X86Architecture: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputValidationErrors9Type. -const ( - ProjectHibernating ProjectUpgradeEligibilityResponseOutputValidationErrors9Type = "project_hibernating" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputValidationErrors9Type enum. -func (e ProjectUpgradeEligibilityResponseOutputValidationErrors9Type) Valid() bool { - switch e { - case ProjectHibernating: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputWarnings0Type. -const ( - PgGraphqlIntrospectionChange ProjectUpgradeEligibilityResponseOutputWarnings0Type = "pg_graphql_introspection_change" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputWarnings0Type enum. -func (e ProjectUpgradeEligibilityResponseOutputWarnings0Type) Valid() bool { - switch e { - case PgGraphqlIntrospectionChange: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputWarnings1Type. -const ( - LtreeReindexRequired ProjectUpgradeEligibilityResponseOutputWarnings1Type = "ltree_reindex_required" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputWarnings1Type enum. -func (e ProjectUpgradeEligibilityResponseOutputWarnings1Type) Valid() bool { - switch e { - case LtreeReindexRequired: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputWarnings2Type. -const ( - OperatorEstimatorGate ProjectUpgradeEligibilityResponseOutputWarnings2Type = "operator_estimator_gate" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputWarnings2Type enum. -func (e ProjectUpgradeEligibilityResponseOutputWarnings2Type) Valid() bool { - switch e { - case OperatorEstimatorGate: - return true - default: - return false - } -} - -// Defines values for ProjectUpgradeEligibilityResponseOutputWarnings3Type. -const ( - BtreeGistNanReindex ProjectUpgradeEligibilityResponseOutputWarnings3Type = "btree_gist_nan_reindex" -) - -// Valid indicates whether the value is a known member of the ProjectUpgradeEligibilityResponseOutputWarnings3Type enum. -func (e ProjectUpgradeEligibilityResponseOutputWarnings3Type) Valid() bool { - switch e { - case BtreeGistNanReindex: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSmartGroupCode. -const ( - RegionsInfoV1OutputAllSmartGroupCodeAmericas RegionsInfoV1OutputAllSmartGroupCode = "americas" - RegionsInfoV1OutputAllSmartGroupCodeApac RegionsInfoV1OutputAllSmartGroupCode = "apac" - RegionsInfoV1OutputAllSmartGroupCodeEmea RegionsInfoV1OutputAllSmartGroupCode = "emea" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSmartGroupCode enum. -func (e RegionsInfoV1OutputAllSmartGroupCode) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSmartGroupCodeAmericas: - return true - case RegionsInfoV1OutputAllSmartGroupCodeApac: - return true - case RegionsInfoV1OutputAllSmartGroupCodeEmea: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSmartGroupType. -const ( - RegionsInfoV1OutputAllSmartGroupTypeSmartGroup RegionsInfoV1OutputAllSmartGroupType = "smartGroup" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSmartGroupType enum. -func (e RegionsInfoV1OutputAllSmartGroupType) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSmartGroupTypeSmartGroup: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSpecificCode. -const ( - RegionsInfoV1OutputAllSpecificCodeApEast1 RegionsInfoV1OutputAllSpecificCode = "ap-east-1" - RegionsInfoV1OutputAllSpecificCodeApNortheast1 RegionsInfoV1OutputAllSpecificCode = "ap-northeast-1" - RegionsInfoV1OutputAllSpecificCodeApNortheast2 RegionsInfoV1OutputAllSpecificCode = "ap-northeast-2" - RegionsInfoV1OutputAllSpecificCodeApSouth1 RegionsInfoV1OutputAllSpecificCode = "ap-south-1" - RegionsInfoV1OutputAllSpecificCodeApSoutheast1 RegionsInfoV1OutputAllSpecificCode = "ap-southeast-1" - RegionsInfoV1OutputAllSpecificCodeApSoutheast2 RegionsInfoV1OutputAllSpecificCode = "ap-southeast-2" - RegionsInfoV1OutputAllSpecificCodeCaCentral1 RegionsInfoV1OutputAllSpecificCode = "ca-central-1" - RegionsInfoV1OutputAllSpecificCodeEuCentral1 RegionsInfoV1OutputAllSpecificCode = "eu-central-1" - RegionsInfoV1OutputAllSpecificCodeEuCentral2 RegionsInfoV1OutputAllSpecificCode = "eu-central-2" - RegionsInfoV1OutputAllSpecificCodeEuNorth1 RegionsInfoV1OutputAllSpecificCode = "eu-north-1" - RegionsInfoV1OutputAllSpecificCodeEuWest1 RegionsInfoV1OutputAllSpecificCode = "eu-west-1" - RegionsInfoV1OutputAllSpecificCodeEuWest2 RegionsInfoV1OutputAllSpecificCode = "eu-west-2" - RegionsInfoV1OutputAllSpecificCodeEuWest3 RegionsInfoV1OutputAllSpecificCode = "eu-west-3" - RegionsInfoV1OutputAllSpecificCodeSaEast1 RegionsInfoV1OutputAllSpecificCode = "sa-east-1" - RegionsInfoV1OutputAllSpecificCodeUsEast1 RegionsInfoV1OutputAllSpecificCode = "us-east-1" - RegionsInfoV1OutputAllSpecificCodeUsEast2 RegionsInfoV1OutputAllSpecificCode = "us-east-2" - RegionsInfoV1OutputAllSpecificCodeUsWest1 RegionsInfoV1OutputAllSpecificCode = "us-west-1" - RegionsInfoV1OutputAllSpecificCodeUsWest2 RegionsInfoV1OutputAllSpecificCode = "us-west-2" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSpecificCode enum. -func (e RegionsInfoV1OutputAllSpecificCode) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSpecificCodeApEast1: - return true - case RegionsInfoV1OutputAllSpecificCodeApNortheast1: - return true - case RegionsInfoV1OutputAllSpecificCodeApNortheast2: - return true - case RegionsInfoV1OutputAllSpecificCodeApSouth1: - return true - case RegionsInfoV1OutputAllSpecificCodeApSoutheast1: - return true - case RegionsInfoV1OutputAllSpecificCodeApSoutheast2: - return true - case RegionsInfoV1OutputAllSpecificCodeCaCentral1: - return true - case RegionsInfoV1OutputAllSpecificCodeEuCentral1: - return true - case RegionsInfoV1OutputAllSpecificCodeEuCentral2: - return true - case RegionsInfoV1OutputAllSpecificCodeEuNorth1: - return true - case RegionsInfoV1OutputAllSpecificCodeEuWest1: - return true - case RegionsInfoV1OutputAllSpecificCodeEuWest2: - return true - case RegionsInfoV1OutputAllSpecificCodeEuWest3: - return true - case RegionsInfoV1OutputAllSpecificCodeSaEast1: - return true - case RegionsInfoV1OutputAllSpecificCodeUsEast1: - return true - case RegionsInfoV1OutputAllSpecificCodeUsEast2: - return true - case RegionsInfoV1OutputAllSpecificCodeUsWest1: - return true - case RegionsInfoV1OutputAllSpecificCodeUsWest2: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSpecificProvider. -const ( - RegionsInfoV1OutputAllSpecificProviderAWS RegionsInfoV1OutputAllSpecificProvider = "AWS" - RegionsInfoV1OutputAllSpecificProviderAWSK8S RegionsInfoV1OutputAllSpecificProvider = "AWS_K8S" - RegionsInfoV1OutputAllSpecificProviderAWSNIMBUS RegionsInfoV1OutputAllSpecificProvider = "AWS_NIMBUS" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSpecificProvider enum. -func (e RegionsInfoV1OutputAllSpecificProvider) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSpecificProviderAWS: - return true - case RegionsInfoV1OutputAllSpecificProviderAWSK8S: - return true - case RegionsInfoV1OutputAllSpecificProviderAWSNIMBUS: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSpecificStatus. -const ( - RegionsInfoV1OutputAllSpecificStatusCapacity RegionsInfoV1OutputAllSpecificStatus = "capacity" - RegionsInfoV1OutputAllSpecificStatusOther RegionsInfoV1OutputAllSpecificStatus = "other" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSpecificStatus enum. -func (e RegionsInfoV1OutputAllSpecificStatus) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSpecificStatusCapacity: - return true - case RegionsInfoV1OutputAllSpecificStatusOther: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputAllSpecificType. -const ( - RegionsInfoV1OutputAllSpecificTypeSpecific RegionsInfoV1OutputAllSpecificType = "specific" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputAllSpecificType enum. -func (e RegionsInfoV1OutputAllSpecificType) Valid() bool { - switch e { - case RegionsInfoV1OutputAllSpecificTypeSpecific: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSmartGroupCode. -const ( - RegionsInfoV1OutputRecommendationsSmartGroupCodeAmericas RegionsInfoV1OutputRecommendationsSmartGroupCode = "americas" - RegionsInfoV1OutputRecommendationsSmartGroupCodeApac RegionsInfoV1OutputRecommendationsSmartGroupCode = "apac" - RegionsInfoV1OutputRecommendationsSmartGroupCodeEmea RegionsInfoV1OutputRecommendationsSmartGroupCode = "emea" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSmartGroupCode enum. -func (e RegionsInfoV1OutputRecommendationsSmartGroupCode) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSmartGroupCodeAmericas: - return true - case RegionsInfoV1OutputRecommendationsSmartGroupCodeApac: - return true - case RegionsInfoV1OutputRecommendationsSmartGroupCodeEmea: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSmartGroupType. -const ( - RegionsInfoV1OutputRecommendationsSmartGroupTypeSmartGroup RegionsInfoV1OutputRecommendationsSmartGroupType = "smartGroup" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSmartGroupType enum. -func (e RegionsInfoV1OutputRecommendationsSmartGroupType) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSmartGroupTypeSmartGroup: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSpecificCode. -const ( - RegionsInfoV1OutputRecommendationsSpecificCodeApEast1 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-east-1" - RegionsInfoV1OutputRecommendationsSpecificCodeApNortheast1 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-northeast-1" - RegionsInfoV1OutputRecommendationsSpecificCodeApNortheast2 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-northeast-2" - RegionsInfoV1OutputRecommendationsSpecificCodeApSouth1 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-south-1" - RegionsInfoV1OutputRecommendationsSpecificCodeApSoutheast1 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-southeast-1" - RegionsInfoV1OutputRecommendationsSpecificCodeApSoutheast2 RegionsInfoV1OutputRecommendationsSpecificCode = "ap-southeast-2" - RegionsInfoV1OutputRecommendationsSpecificCodeCaCentral1 RegionsInfoV1OutputRecommendationsSpecificCode = "ca-central-1" - RegionsInfoV1OutputRecommendationsSpecificCodeEuCentral1 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-central-1" - RegionsInfoV1OutputRecommendationsSpecificCodeEuCentral2 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-central-2" - RegionsInfoV1OutputRecommendationsSpecificCodeEuNorth1 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-north-1" - RegionsInfoV1OutputRecommendationsSpecificCodeEuWest1 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-west-1" - RegionsInfoV1OutputRecommendationsSpecificCodeEuWest2 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-west-2" - RegionsInfoV1OutputRecommendationsSpecificCodeEuWest3 RegionsInfoV1OutputRecommendationsSpecificCode = "eu-west-3" - RegionsInfoV1OutputRecommendationsSpecificCodeSaEast1 RegionsInfoV1OutputRecommendationsSpecificCode = "sa-east-1" - RegionsInfoV1OutputRecommendationsSpecificCodeUsEast1 RegionsInfoV1OutputRecommendationsSpecificCode = "us-east-1" - RegionsInfoV1OutputRecommendationsSpecificCodeUsEast2 RegionsInfoV1OutputRecommendationsSpecificCode = "us-east-2" - RegionsInfoV1OutputRecommendationsSpecificCodeUsWest1 RegionsInfoV1OutputRecommendationsSpecificCode = "us-west-1" - RegionsInfoV1OutputRecommendationsSpecificCodeUsWest2 RegionsInfoV1OutputRecommendationsSpecificCode = "us-west-2" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSpecificCode enum. -func (e RegionsInfoV1OutputRecommendationsSpecificCode) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSpecificCodeApEast1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeApNortheast1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeApNortheast2: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeApSouth1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeApSoutheast1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeApSoutheast2: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeCaCentral1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuCentral1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuCentral2: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuNorth1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuWest1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuWest2: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeEuWest3: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeSaEast1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeUsEast1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeUsEast2: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeUsWest1: - return true - case RegionsInfoV1OutputRecommendationsSpecificCodeUsWest2: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSpecificProvider. -const ( - RegionsInfoV1OutputRecommendationsSpecificProviderAWS RegionsInfoV1OutputRecommendationsSpecificProvider = "AWS" - RegionsInfoV1OutputRecommendationsSpecificProviderAWSK8S RegionsInfoV1OutputRecommendationsSpecificProvider = "AWS_K8S" - RegionsInfoV1OutputRecommendationsSpecificProviderAWSNIMBUS RegionsInfoV1OutputRecommendationsSpecificProvider = "AWS_NIMBUS" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSpecificProvider enum. -func (e RegionsInfoV1OutputRecommendationsSpecificProvider) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSpecificProviderAWS: - return true - case RegionsInfoV1OutputRecommendationsSpecificProviderAWSK8S: - return true - case RegionsInfoV1OutputRecommendationsSpecificProviderAWSNIMBUS: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSpecificStatus. -const ( - RegionsInfoV1OutputRecommendationsSpecificStatusCapacity RegionsInfoV1OutputRecommendationsSpecificStatus = "capacity" - RegionsInfoV1OutputRecommendationsSpecificStatusOther RegionsInfoV1OutputRecommendationsSpecificStatus = "other" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSpecificStatus enum. -func (e RegionsInfoV1OutputRecommendationsSpecificStatus) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSpecificStatusCapacity: - return true - case RegionsInfoV1OutputRecommendationsSpecificStatusOther: - return true - default: - return false - } -} - -// Defines values for RegionsInfoV1OutputRecommendationsSpecificType. -const ( - RegionsInfoV1OutputRecommendationsSpecificTypeSpecific RegionsInfoV1OutputRecommendationsSpecificType = "specific" -) - -// Valid indicates whether the value is a known member of the RegionsInfoV1OutputRecommendationsSpecificType enum. -func (e RegionsInfoV1OutputRecommendationsSpecificType) Valid() bool { - switch e { - case RegionsInfoV1OutputRecommendationsSpecificTypeSpecific: - return true - default: - return false - } -} - -// Defines values for SetUpReadReplicaBodyReadReplicaRegion. -const ( - SetUpReadReplicaBodyReadReplicaRegionApEast1 SetUpReadReplicaBodyReadReplicaRegion = "ap-east-1" - SetUpReadReplicaBodyReadReplicaRegionApNortheast1 SetUpReadReplicaBodyReadReplicaRegion = "ap-northeast-1" - SetUpReadReplicaBodyReadReplicaRegionApNortheast2 SetUpReadReplicaBodyReadReplicaRegion = "ap-northeast-2" - SetUpReadReplicaBodyReadReplicaRegionApSouth1 SetUpReadReplicaBodyReadReplicaRegion = "ap-south-1" - SetUpReadReplicaBodyReadReplicaRegionApSoutheast1 SetUpReadReplicaBodyReadReplicaRegion = "ap-southeast-1" - SetUpReadReplicaBodyReadReplicaRegionApSoutheast2 SetUpReadReplicaBodyReadReplicaRegion = "ap-southeast-2" - SetUpReadReplicaBodyReadReplicaRegionCaCentral1 SetUpReadReplicaBodyReadReplicaRegion = "ca-central-1" - SetUpReadReplicaBodyReadReplicaRegionEuCentral1 SetUpReadReplicaBodyReadReplicaRegion = "eu-central-1" - SetUpReadReplicaBodyReadReplicaRegionEuCentral2 SetUpReadReplicaBodyReadReplicaRegion = "eu-central-2" - SetUpReadReplicaBodyReadReplicaRegionEuNorth1 SetUpReadReplicaBodyReadReplicaRegion = "eu-north-1" - SetUpReadReplicaBodyReadReplicaRegionEuWest1 SetUpReadReplicaBodyReadReplicaRegion = "eu-west-1" - SetUpReadReplicaBodyReadReplicaRegionEuWest2 SetUpReadReplicaBodyReadReplicaRegion = "eu-west-2" - SetUpReadReplicaBodyReadReplicaRegionEuWest3 SetUpReadReplicaBodyReadReplicaRegion = "eu-west-3" - SetUpReadReplicaBodyReadReplicaRegionSaEast1 SetUpReadReplicaBodyReadReplicaRegion = "sa-east-1" - SetUpReadReplicaBodyReadReplicaRegionUsEast1 SetUpReadReplicaBodyReadReplicaRegion = "us-east-1" - SetUpReadReplicaBodyReadReplicaRegionUsEast2 SetUpReadReplicaBodyReadReplicaRegion = "us-east-2" - SetUpReadReplicaBodyReadReplicaRegionUsWest1 SetUpReadReplicaBodyReadReplicaRegion = "us-west-1" - SetUpReadReplicaBodyReadReplicaRegionUsWest2 SetUpReadReplicaBodyReadReplicaRegion = "us-west-2" -) - -// Valid indicates whether the value is a known member of the SetUpReadReplicaBodyReadReplicaRegion enum. -func (e SetUpReadReplicaBodyReadReplicaRegion) Valid() bool { - switch e { - case SetUpReadReplicaBodyReadReplicaRegionApEast1: - return true - case SetUpReadReplicaBodyReadReplicaRegionApNortheast1: - return true - case SetUpReadReplicaBodyReadReplicaRegionApNortheast2: - return true - case SetUpReadReplicaBodyReadReplicaRegionApSouth1: - return true - case SetUpReadReplicaBodyReadReplicaRegionApSoutheast1: - return true - case SetUpReadReplicaBodyReadReplicaRegionApSoutheast2: - return true - case SetUpReadReplicaBodyReadReplicaRegionCaCentral1: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuCentral1: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuCentral2: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuNorth1: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuWest1: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuWest2: - return true - case SetUpReadReplicaBodyReadReplicaRegionEuWest3: - return true - case SetUpReadReplicaBodyReadReplicaRegionSaEast1: - return true - case SetUpReadReplicaBodyReadReplicaRegionUsEast1: - return true - case SetUpReadReplicaBodyReadReplicaRegionUsEast2: - return true - case SetUpReadReplicaBodyReadReplicaRegionUsWest1: - return true - case SetUpReadReplicaBodyReadReplicaRegionUsWest2: - return true - default: - return false - } -} - -// Defines values for SigningKeyResponseOutputAlgorithm. -const ( - SigningKeyResponseOutputAlgorithmES256 SigningKeyResponseOutputAlgorithm = "ES256" - SigningKeyResponseOutputAlgorithmEdDSA SigningKeyResponseOutputAlgorithm = "EdDSA" - SigningKeyResponseOutputAlgorithmHS256 SigningKeyResponseOutputAlgorithm = "HS256" - SigningKeyResponseOutputAlgorithmRS256 SigningKeyResponseOutputAlgorithm = "RS256" -) - -// Valid indicates whether the value is a known member of the SigningKeyResponseOutputAlgorithm enum. -func (e SigningKeyResponseOutputAlgorithm) Valid() bool { - switch e { - case SigningKeyResponseOutputAlgorithmES256: - return true - case SigningKeyResponseOutputAlgorithmEdDSA: - return true - case SigningKeyResponseOutputAlgorithmHS256: - return true - case SigningKeyResponseOutputAlgorithmRS256: - return true - default: - return false - } -} - -// Defines values for SigningKeyResponseOutputStatus. -const ( - SigningKeyResponseOutputStatusInUse SigningKeyResponseOutputStatus = "in_use" - SigningKeyResponseOutputStatusPreviouslyUsed SigningKeyResponseOutputStatus = "previously_used" - SigningKeyResponseOutputStatusRevoked SigningKeyResponseOutputStatus = "revoked" - SigningKeyResponseOutputStatusStandby SigningKeyResponseOutputStatus = "standby" -) - -// Valid indicates whether the value is a known member of the SigningKeyResponseOutputStatus enum. -func (e SigningKeyResponseOutputStatus) Valid() bool { - switch e { - case SigningKeyResponseOutputStatusInUse: - return true - case SigningKeyResponseOutputStatusPreviouslyUsed: - return true - case SigningKeyResponseOutputStatusRevoked: - return true - case SigningKeyResponseOutputStatusStandby: - return true - default: - return false - } -} - -// Defines values for SigningKeysResponseOutputKeysAlgorithm. -const ( - SigningKeysResponseOutputKeysAlgorithmES256 SigningKeysResponseOutputKeysAlgorithm = "ES256" - SigningKeysResponseOutputKeysAlgorithmEdDSA SigningKeysResponseOutputKeysAlgorithm = "EdDSA" - SigningKeysResponseOutputKeysAlgorithmHS256 SigningKeysResponseOutputKeysAlgorithm = "HS256" - SigningKeysResponseOutputKeysAlgorithmRS256 SigningKeysResponseOutputKeysAlgorithm = "RS256" -) - -// Valid indicates whether the value is a known member of the SigningKeysResponseOutputKeysAlgorithm enum. -func (e SigningKeysResponseOutputKeysAlgorithm) Valid() bool { - switch e { - case SigningKeysResponseOutputKeysAlgorithmES256: - return true - case SigningKeysResponseOutputKeysAlgorithmEdDSA: - return true - case SigningKeysResponseOutputKeysAlgorithmHS256: - return true - case SigningKeysResponseOutputKeysAlgorithmRS256: - return true - default: - return false - } -} - -// Defines values for SigningKeysResponseOutputKeysStatus. -const ( - SigningKeysResponseOutputKeysStatusInUse SigningKeysResponseOutputKeysStatus = "in_use" - SigningKeysResponseOutputKeysStatusPreviouslyUsed SigningKeysResponseOutputKeysStatus = "previously_used" - SigningKeysResponseOutputKeysStatusRevoked SigningKeysResponseOutputKeysStatus = "revoked" - SigningKeysResponseOutputKeysStatusStandby SigningKeysResponseOutputKeysStatus = "standby" -) - -// Valid indicates whether the value is a known member of the SigningKeysResponseOutputKeysStatus enum. -func (e SigningKeysResponseOutputKeysStatus) Valid() bool { - switch e { - case SigningKeysResponseOutputKeysStatusInUse: - return true - case SigningKeysResponseOutputKeysStatusPreviouslyUsed: - return true - case SigningKeysResponseOutputKeysStatusRevoked: - return true - case SigningKeysResponseOutputKeysStatusStandby: - return true - default: - return false - } -} - -// Defines values for SnippetListOutputDataType. -const ( - SnippetListOutputDataTypeSql SnippetListOutputDataType = "sql" -) - -// Valid indicates whether the value is a known member of the SnippetListOutputDataType enum. -func (e SnippetListOutputDataType) Valid() bool { - switch e { - case SnippetListOutputDataTypeSql: - return true - default: - return false - } -} - -// Defines values for SnippetListOutputDataVisibility. -const ( - SnippetListOutputDataVisibilityOrg SnippetListOutputDataVisibility = "org" - SnippetListOutputDataVisibilityProject SnippetListOutputDataVisibility = "project" - SnippetListOutputDataVisibilityPublic SnippetListOutputDataVisibility = "public" - SnippetListOutputDataVisibilityUser SnippetListOutputDataVisibility = "user" -) - -// Valid indicates whether the value is a known member of the SnippetListOutputDataVisibility enum. -func (e SnippetListOutputDataVisibility) Valid() bool { - switch e { - case SnippetListOutputDataVisibilityOrg: - return true - case SnippetListOutputDataVisibilityProject: - return true - case SnippetListOutputDataVisibilityPublic: - return true - case SnippetListOutputDataVisibilityUser: - return true - default: - return false - } -} - -// Defines values for SnippetResponseOutputType. -const ( - SnippetResponseOutputTypeSql SnippetResponseOutputType = "sql" -) - -// Valid indicates whether the value is a known member of the SnippetResponseOutputType enum. -func (e SnippetResponseOutputType) Valid() bool { - switch e { - case SnippetResponseOutputTypeSql: - return true - default: - return false - } -} - -// Defines values for SnippetResponseOutputVisibility. -const ( - SnippetResponseOutputVisibilityOrg SnippetResponseOutputVisibility = "org" - SnippetResponseOutputVisibilityProject SnippetResponseOutputVisibility = "project" - SnippetResponseOutputVisibilityPublic SnippetResponseOutputVisibility = "public" - SnippetResponseOutputVisibilityUser SnippetResponseOutputVisibility = "user" -) - -// Valid indicates whether the value is a known member of the SnippetResponseOutputVisibility enum. -func (e SnippetResponseOutputVisibility) Valid() bool { - switch e { - case SnippetResponseOutputVisibilityOrg: - return true - case SnippetResponseOutputVisibilityProject: - return true - case SnippetResponseOutputVisibilityPublic: - return true - case SnippetResponseOutputVisibilityUser: - return true - default: - return false - } -} - -// Defines values for StorageConfigResponseOutputExternalUpstreamTarget. -const ( - StorageConfigResponseOutputExternalUpstreamTargetCanary StorageConfigResponseOutputExternalUpstreamTarget = "canary" - StorageConfigResponseOutputExternalUpstreamTargetMain StorageConfigResponseOutputExternalUpstreamTarget = "main" -) - -// Valid indicates whether the value is a known member of the StorageConfigResponseOutputExternalUpstreamTarget enum. -func (e StorageConfigResponseOutputExternalUpstreamTarget) Valid() bool { - switch e { - case StorageConfigResponseOutputExternalUpstreamTargetCanary: - return true - case StorageConfigResponseOutputExternalUpstreamTargetMain: - return true - default: - return false - } -} - -// Defines values for SupavisorConfigResponseOutputDatabaseType. -const ( - SupavisorConfigResponseOutputDatabaseTypePRIMARY SupavisorConfigResponseOutputDatabaseType = "PRIMARY" - SupavisorConfigResponseOutputDatabaseTypeREADREPLICA SupavisorConfigResponseOutputDatabaseType = "READ_REPLICA" -) - -// Valid indicates whether the value is a known member of the SupavisorConfigResponseOutputDatabaseType enum. -func (e SupavisorConfigResponseOutputDatabaseType) Valid() bool { - switch e { - case SupavisorConfigResponseOutputDatabaseTypePRIMARY: - return true - case SupavisorConfigResponseOutputDatabaseTypeREADREPLICA: - return true - default: - return false - } -} - -// Defines values for SupavisorConfigResponseOutputPoolMode. -const ( - SupavisorConfigResponseOutputPoolModeSession SupavisorConfigResponseOutputPoolMode = "session" - SupavisorConfigResponseOutputPoolModeTransaction SupavisorConfigResponseOutputPoolMode = "transaction" -) - -// Valid indicates whether the value is a known member of the SupavisorConfigResponseOutputPoolMode enum. -func (e SupavisorConfigResponseOutputPoolMode) Valid() bool { - switch e { - case SupavisorConfigResponseOutputPoolModeSession: - return true - case SupavisorConfigResponseOutputPoolModeTransaction: - return true - default: - return false - } -} - -// Defines values for UpdateAuthConfigBodyDbMaxPoolSizeUnit. -const ( - UpdateAuthConfigBodyDbMaxPoolSizeUnitConnections UpdateAuthConfigBodyDbMaxPoolSizeUnit = "connections" - UpdateAuthConfigBodyDbMaxPoolSizeUnitLessThannil UpdateAuthConfigBodyDbMaxPoolSizeUnit = "" - UpdateAuthConfigBodyDbMaxPoolSizeUnitPercent UpdateAuthConfigBodyDbMaxPoolSizeUnit = "percent" -) - -// Valid indicates whether the value is a known member of the UpdateAuthConfigBodyDbMaxPoolSizeUnit enum. -func (e UpdateAuthConfigBodyDbMaxPoolSizeUnit) Valid() bool { - switch e { - case UpdateAuthConfigBodyDbMaxPoolSizeUnitConnections: - return true - case UpdateAuthConfigBodyDbMaxPoolSizeUnitLessThannil: - return true - case UpdateAuthConfigBodyDbMaxPoolSizeUnitPercent: - return true - default: - return false - } -} - -// Defines values for UpdateAuthConfigBodyPasswordRequiredCharacters. -const ( - UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 UpdateAuthConfigBodyPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" - UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891 UpdateAuthConfigBodyPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789" - UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892 UpdateAuthConfigBodyPasswordRequiredCharacters = "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~" - UpdateAuthConfigBodyPasswordRequiredCharactersEmpty UpdateAuthConfigBodyPasswordRequiredCharacters = "" -) - -// Valid indicates whether the value is a known member of the UpdateAuthConfigBodyPasswordRequiredCharacters enum. -func (e UpdateAuthConfigBodyPasswordRequiredCharacters) Valid() bool { - switch e { - case UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789: - return true - case UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891: - return true - case UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892: - return true - case UpdateAuthConfigBodyPasswordRequiredCharactersEmpty: - return true - default: - return false - } -} - -// Defines values for UpdateAuthConfigBodySecurityCaptchaProvider. -const ( - UpdateAuthConfigBodySecurityCaptchaProviderHcaptcha UpdateAuthConfigBodySecurityCaptchaProvider = "hcaptcha" - UpdateAuthConfigBodySecurityCaptchaProviderLessThannil UpdateAuthConfigBodySecurityCaptchaProvider = "" - UpdateAuthConfigBodySecurityCaptchaProviderTurnstile UpdateAuthConfigBodySecurityCaptchaProvider = "turnstile" -) - -// Valid indicates whether the value is a known member of the UpdateAuthConfigBodySecurityCaptchaProvider enum. -func (e UpdateAuthConfigBodySecurityCaptchaProvider) Valid() bool { - switch e { - case UpdateAuthConfigBodySecurityCaptchaProviderHcaptcha: - return true - case UpdateAuthConfigBodySecurityCaptchaProviderLessThannil: - return true - case UpdateAuthConfigBodySecurityCaptchaProviderTurnstile: - return true - default: - return false - } -} - -// Defines values for UpdateAuthConfigBodySmsProvider. -const ( - LessThannil UpdateAuthConfigBodySmsProvider = "" - Messagebird UpdateAuthConfigBodySmsProvider = "messagebird" - Textlocal UpdateAuthConfigBodySmsProvider = "textlocal" - Twilio UpdateAuthConfigBodySmsProvider = "twilio" - TwilioVerify UpdateAuthConfigBodySmsProvider = "twilio_verify" - Vonage UpdateAuthConfigBodySmsProvider = "vonage" -) - -// Valid indicates whether the value is a known member of the UpdateAuthConfigBodySmsProvider enum. -func (e UpdateAuthConfigBodySmsProvider) Valid() bool { - switch e { - case LessThannil: - return true - case Messagebird: - return true - case Textlocal: - return true - case Twilio: - return true - case TwilioVerify: - return true - case Vonage: - return true - default: - return false - } -} - -// Defines values for UpdateBranchBodyStatus. -const ( - UpdateBranchBodyStatusCREATINGPROJECT UpdateBranchBodyStatus = "CREATING_PROJECT" - UpdateBranchBodyStatusFUNCTIONSDEPLOYED UpdateBranchBodyStatus = "FUNCTIONS_DEPLOYED" - UpdateBranchBodyStatusFUNCTIONSFAILED UpdateBranchBodyStatus = "FUNCTIONS_FAILED" - UpdateBranchBodyStatusMIGRATIONSFAILED UpdateBranchBodyStatus = "MIGRATIONS_FAILED" - UpdateBranchBodyStatusMIGRATIONSPASSED UpdateBranchBodyStatus = "MIGRATIONS_PASSED" - UpdateBranchBodyStatusRUNNINGMIGRATIONS UpdateBranchBodyStatus = "RUNNING_MIGRATIONS" -) - -// Valid indicates whether the value is a known member of the UpdateBranchBodyStatus enum. -func (e UpdateBranchBodyStatus) Valid() bool { - switch e { - case UpdateBranchBodyStatusCREATINGPROJECT: - return true - case UpdateBranchBodyStatusFUNCTIONSDEPLOYED: - return true - case UpdateBranchBodyStatusFUNCTIONSFAILED: - return true - case UpdateBranchBodyStatusMIGRATIONSFAILED: - return true - case UpdateBranchBodyStatusMIGRATIONSPASSED: - return true - case UpdateBranchBodyStatusRUNNINGMIGRATIONS: - return true - default: - return false - } -} - -// Defines values for UpdateCustomHostnameResponseOutputStatus. -const ( - N1NotStarted UpdateCustomHostnameResponseOutputStatus = "1_not_started" - N2Initiated UpdateCustomHostnameResponseOutputStatus = "2_initiated" - N3ChallengeVerified UpdateCustomHostnameResponseOutputStatus = "3_challenge_verified" - N4OriginSetupCompleted UpdateCustomHostnameResponseOutputStatus = "4_origin_setup_completed" - N5ServicesReconfigured UpdateCustomHostnameResponseOutputStatus = "5_services_reconfigured" -) - -// Valid indicates whether the value is a known member of the UpdateCustomHostnameResponseOutputStatus enum. -func (e UpdateCustomHostnameResponseOutputStatus) Valid() bool { - switch e { - case N1NotStarted: - return true - case N2Initiated: - return true - case N3ChallengeVerified: - return true - case N4OriginSetupCompleted: - return true - case N5ServicesReconfigured: - return true - default: - return false - } -} - -// Defines values for UpdatePostgresConfigBodySessionReplicationRole. -const ( - UpdatePostgresConfigBodySessionReplicationRoleLocal UpdatePostgresConfigBodySessionReplicationRole = "local" - UpdatePostgresConfigBodySessionReplicationRoleOrigin UpdatePostgresConfigBodySessionReplicationRole = "origin" - UpdatePostgresConfigBodySessionReplicationRoleReplica UpdatePostgresConfigBodySessionReplicationRole = "replica" -) - -// Valid indicates whether the value is a known member of the UpdatePostgresConfigBodySessionReplicationRole enum. -func (e UpdatePostgresConfigBodySessionReplicationRole) Valid() bool { - switch e { - case UpdatePostgresConfigBodySessionReplicationRoleLocal: - return true - case UpdatePostgresConfigBodySessionReplicationRoleOrigin: - return true - case UpdatePostgresConfigBodySessionReplicationRoleReplica: - return true - default: - return false - } -} - -// Defines values for UpdateProviderBodyNameIdFormat. -const ( - UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatEmailAddress UpdateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" - UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatUnspecified UpdateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" - UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatPersistent UpdateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatTransient UpdateProviderBodyNameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" -) - -// Valid indicates whether the value is a known member of the UpdateProviderBodyNameIdFormat enum. -func (e UpdateProviderBodyNameIdFormat) Valid() bool { - switch e { - case UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatEmailAddress: - return true - case UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML11NameidFormatUnspecified: - return true - case UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatPersistent: - return true - case UpdateProviderBodyNameIdFormatUrnOasisNamesTcSAML20NameidFormatTransient: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyClone. -const ( - UpdateRunStatusBodyCloneCREATED UpdateRunStatusBodyClone = "CREATED" - UpdateRunStatusBodyCloneDEAD UpdateRunStatusBodyClone = "DEAD" - UpdateRunStatusBodyCloneEXITED UpdateRunStatusBodyClone = "EXITED" - UpdateRunStatusBodyClonePAUSED UpdateRunStatusBodyClone = "PAUSED" - UpdateRunStatusBodyCloneREMOVING UpdateRunStatusBodyClone = "REMOVING" - UpdateRunStatusBodyCloneRESTARTING UpdateRunStatusBodyClone = "RESTARTING" - UpdateRunStatusBodyCloneRUNNING UpdateRunStatusBodyClone = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyClone enum. -func (e UpdateRunStatusBodyClone) Valid() bool { - switch e { - case UpdateRunStatusBodyCloneCREATED: - return true - case UpdateRunStatusBodyCloneDEAD: - return true - case UpdateRunStatusBodyCloneEXITED: - return true - case UpdateRunStatusBodyClonePAUSED: - return true - case UpdateRunStatusBodyCloneREMOVING: - return true - case UpdateRunStatusBodyCloneRESTARTING: - return true - case UpdateRunStatusBodyCloneRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyConfigure. -const ( - UpdateRunStatusBodyConfigureCREATED UpdateRunStatusBodyConfigure = "CREATED" - UpdateRunStatusBodyConfigureDEAD UpdateRunStatusBodyConfigure = "DEAD" - UpdateRunStatusBodyConfigureEXITED UpdateRunStatusBodyConfigure = "EXITED" - UpdateRunStatusBodyConfigurePAUSED UpdateRunStatusBodyConfigure = "PAUSED" - UpdateRunStatusBodyConfigureREMOVING UpdateRunStatusBodyConfigure = "REMOVING" - UpdateRunStatusBodyConfigureRESTARTING UpdateRunStatusBodyConfigure = "RESTARTING" - UpdateRunStatusBodyConfigureRUNNING UpdateRunStatusBodyConfigure = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyConfigure enum. -func (e UpdateRunStatusBodyConfigure) Valid() bool { - switch e { - case UpdateRunStatusBodyConfigureCREATED: - return true - case UpdateRunStatusBodyConfigureDEAD: - return true - case UpdateRunStatusBodyConfigureEXITED: - return true - case UpdateRunStatusBodyConfigurePAUSED: - return true - case UpdateRunStatusBodyConfigureREMOVING: - return true - case UpdateRunStatusBodyConfigureRESTARTING: - return true - case UpdateRunStatusBodyConfigureRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyDeploy. -const ( - UpdateRunStatusBodyDeployCREATED UpdateRunStatusBodyDeploy = "CREATED" - UpdateRunStatusBodyDeployDEAD UpdateRunStatusBodyDeploy = "DEAD" - UpdateRunStatusBodyDeployEXITED UpdateRunStatusBodyDeploy = "EXITED" - UpdateRunStatusBodyDeployPAUSED UpdateRunStatusBodyDeploy = "PAUSED" - UpdateRunStatusBodyDeployREMOVING UpdateRunStatusBodyDeploy = "REMOVING" - UpdateRunStatusBodyDeployRESTARTING UpdateRunStatusBodyDeploy = "RESTARTING" - UpdateRunStatusBodyDeployRUNNING UpdateRunStatusBodyDeploy = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyDeploy enum. -func (e UpdateRunStatusBodyDeploy) Valid() bool { - switch e { - case UpdateRunStatusBodyDeployCREATED: - return true - case UpdateRunStatusBodyDeployDEAD: - return true - case UpdateRunStatusBodyDeployEXITED: - return true - case UpdateRunStatusBodyDeployPAUSED: - return true - case UpdateRunStatusBodyDeployREMOVING: - return true - case UpdateRunStatusBodyDeployRESTARTING: - return true - case UpdateRunStatusBodyDeployRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyHealth. -const ( - UpdateRunStatusBodyHealthCREATED UpdateRunStatusBodyHealth = "CREATED" - UpdateRunStatusBodyHealthDEAD UpdateRunStatusBodyHealth = "DEAD" - UpdateRunStatusBodyHealthEXITED UpdateRunStatusBodyHealth = "EXITED" - UpdateRunStatusBodyHealthPAUSED UpdateRunStatusBodyHealth = "PAUSED" - UpdateRunStatusBodyHealthREMOVING UpdateRunStatusBodyHealth = "REMOVING" - UpdateRunStatusBodyHealthRESTARTING UpdateRunStatusBodyHealth = "RESTARTING" - UpdateRunStatusBodyHealthRUNNING UpdateRunStatusBodyHealth = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyHealth enum. -func (e UpdateRunStatusBodyHealth) Valid() bool { - switch e { - case UpdateRunStatusBodyHealthCREATED: - return true - case UpdateRunStatusBodyHealthDEAD: - return true - case UpdateRunStatusBodyHealthEXITED: - return true - case UpdateRunStatusBodyHealthPAUSED: - return true - case UpdateRunStatusBodyHealthREMOVING: - return true - case UpdateRunStatusBodyHealthRESTARTING: - return true - case UpdateRunStatusBodyHealthRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyMigrate. -const ( - UpdateRunStatusBodyMigrateCREATED UpdateRunStatusBodyMigrate = "CREATED" - UpdateRunStatusBodyMigrateDEAD UpdateRunStatusBodyMigrate = "DEAD" - UpdateRunStatusBodyMigrateEXITED UpdateRunStatusBodyMigrate = "EXITED" - UpdateRunStatusBodyMigratePAUSED UpdateRunStatusBodyMigrate = "PAUSED" - UpdateRunStatusBodyMigrateREMOVING UpdateRunStatusBodyMigrate = "REMOVING" - UpdateRunStatusBodyMigrateRESTARTING UpdateRunStatusBodyMigrate = "RESTARTING" - UpdateRunStatusBodyMigrateRUNNING UpdateRunStatusBodyMigrate = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyMigrate enum. -func (e UpdateRunStatusBodyMigrate) Valid() bool { - switch e { - case UpdateRunStatusBodyMigrateCREATED: - return true - case UpdateRunStatusBodyMigrateDEAD: - return true - case UpdateRunStatusBodyMigrateEXITED: - return true - case UpdateRunStatusBodyMigratePAUSED: - return true - case UpdateRunStatusBodyMigrateREMOVING: - return true - case UpdateRunStatusBodyMigrateRESTARTING: - return true - case UpdateRunStatusBodyMigrateRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodyPull. -const ( - UpdateRunStatusBodyPullCREATED UpdateRunStatusBodyPull = "CREATED" - UpdateRunStatusBodyPullDEAD UpdateRunStatusBodyPull = "DEAD" - UpdateRunStatusBodyPullEXITED UpdateRunStatusBodyPull = "EXITED" - UpdateRunStatusBodyPullPAUSED UpdateRunStatusBodyPull = "PAUSED" - UpdateRunStatusBodyPullREMOVING UpdateRunStatusBodyPull = "REMOVING" - UpdateRunStatusBodyPullRESTARTING UpdateRunStatusBodyPull = "RESTARTING" - UpdateRunStatusBodyPullRUNNING UpdateRunStatusBodyPull = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodyPull enum. -func (e UpdateRunStatusBodyPull) Valid() bool { - switch e { - case UpdateRunStatusBodyPullCREATED: - return true - case UpdateRunStatusBodyPullDEAD: - return true - case UpdateRunStatusBodyPullEXITED: - return true - case UpdateRunStatusBodyPullPAUSED: - return true - case UpdateRunStatusBodyPullREMOVING: - return true - case UpdateRunStatusBodyPullRESTARTING: - return true - case UpdateRunStatusBodyPullRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusBodySeed. -const ( - UpdateRunStatusBodySeedCREATED UpdateRunStatusBodySeed = "CREATED" - UpdateRunStatusBodySeedDEAD UpdateRunStatusBodySeed = "DEAD" - UpdateRunStatusBodySeedEXITED UpdateRunStatusBodySeed = "EXITED" - UpdateRunStatusBodySeedPAUSED UpdateRunStatusBodySeed = "PAUSED" - UpdateRunStatusBodySeedREMOVING UpdateRunStatusBodySeed = "REMOVING" - UpdateRunStatusBodySeedRESTARTING UpdateRunStatusBodySeed = "RESTARTING" - UpdateRunStatusBodySeedRUNNING UpdateRunStatusBodySeed = "RUNNING" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusBodySeed enum. -func (e UpdateRunStatusBodySeed) Valid() bool { - switch e { - case UpdateRunStatusBodySeedCREATED: - return true - case UpdateRunStatusBodySeedDEAD: - return true - case UpdateRunStatusBodySeedEXITED: - return true - case UpdateRunStatusBodySeedPAUSED: - return true - case UpdateRunStatusBodySeedREMOVING: - return true - case UpdateRunStatusBodySeedRESTARTING: - return true - case UpdateRunStatusBodySeedRUNNING: - return true - default: - return false - } -} - -// Defines values for UpdateRunStatusResponseOutputMessage. -const ( - UpdateRunStatusResponseOutputMessageOk UpdateRunStatusResponseOutputMessage = "ok" -) - -// Valid indicates whether the value is a known member of the UpdateRunStatusResponseOutputMessage enum. -func (e UpdateRunStatusResponseOutputMessage) Valid() bool { - switch e { - case UpdateRunStatusResponseOutputMessageOk: - return true - default: - return false - } -} - -// Defines values for UpdateSigningKeyBodyStatus. -const ( - UpdateSigningKeyBodyStatusInUse UpdateSigningKeyBodyStatus = "in_use" - UpdateSigningKeyBodyStatusPreviouslyUsed UpdateSigningKeyBodyStatus = "previously_used" - UpdateSigningKeyBodyStatusRevoked UpdateSigningKeyBodyStatus = "revoked" - UpdateSigningKeyBodyStatusStandby UpdateSigningKeyBodyStatus = "standby" -) - -// Valid indicates whether the value is a known member of the UpdateSigningKeyBodyStatus enum. -func (e UpdateSigningKeyBodyStatus) Valid() bool { - switch e { - case UpdateSigningKeyBodyStatusInUse: - return true - case UpdateSigningKeyBodyStatusPreviouslyUsed: - return true - case UpdateSigningKeyBodyStatusRevoked: - return true - case UpdateSigningKeyBodyStatusStandby: - return true - default: - return false - } -} - -// Defines values for UpdateStorageConfigBodyExternalUpstreamTarget. -const ( - UpdateStorageConfigBodyExternalUpstreamTargetCanary UpdateStorageConfigBodyExternalUpstreamTarget = "canary" - UpdateStorageConfigBodyExternalUpstreamTargetMain UpdateStorageConfigBodyExternalUpstreamTarget = "main" -) - -// Valid indicates whether the value is a known member of the UpdateStorageConfigBodyExternalUpstreamTarget enum. -func (e UpdateStorageConfigBodyExternalUpstreamTarget) Valid() bool { - switch e { - case UpdateStorageConfigBodyExternalUpstreamTargetCanary: - return true - case UpdateStorageConfigBodyExternalUpstreamTargetMain: - return true - default: - return false - } -} - -// Defines values for UpdateSupavisorConfigBodyPoolMode. -const ( - UpdateSupavisorConfigBodyPoolModeSession UpdateSupavisorConfigBodyPoolMode = "session" - UpdateSupavisorConfigBodyPoolModeTransaction UpdateSupavisorConfigBodyPoolMode = "transaction" -) - -// Valid indicates whether the value is a known member of the UpdateSupavisorConfigBodyPoolMode enum. -func (e UpdateSupavisorConfigBodyPoolMode) Valid() bool { - switch e { - case UpdateSupavisorConfigBodyPoolModeSession: - return true - case UpdateSupavisorConfigBodyPoolModeTransaction: - return true - default: - return false - } -} - -// Defines values for UpgradeDatabaseBodyReleaseChannel. -const ( - UpgradeDatabaseBodyReleaseChannelAlpha UpgradeDatabaseBodyReleaseChannel = "alpha" - UpgradeDatabaseBodyReleaseChannelBeta UpgradeDatabaseBodyReleaseChannel = "beta" - UpgradeDatabaseBodyReleaseChannelGa UpgradeDatabaseBodyReleaseChannel = "ga" - UpgradeDatabaseBodyReleaseChannelInternal UpgradeDatabaseBodyReleaseChannel = "internal" - UpgradeDatabaseBodyReleaseChannelPreview UpgradeDatabaseBodyReleaseChannel = "preview" - UpgradeDatabaseBodyReleaseChannelWithdrawn UpgradeDatabaseBodyReleaseChannel = "withdrawn" -) - -// Valid indicates whether the value is a known member of the UpgradeDatabaseBodyReleaseChannel enum. -func (e UpgradeDatabaseBodyReleaseChannel) Valid() bool { - switch e { - case UpgradeDatabaseBodyReleaseChannelAlpha: - return true - case UpgradeDatabaseBodyReleaseChannelBeta: - return true - case UpgradeDatabaseBodyReleaseChannelGa: - return true - case UpgradeDatabaseBodyReleaseChannelInternal: - return true - case UpgradeDatabaseBodyReleaseChannelPreview: - return true - case UpgradeDatabaseBodyReleaseChannelWithdrawn: - return true - default: - return false - } -} - -// Defines values for V1BackupsResponseOutputBackupsStatus. -const ( - V1BackupsResponseOutputBackupsStatusARCHIVED V1BackupsResponseOutputBackupsStatus = "ARCHIVED" - V1BackupsResponseOutputBackupsStatusCANCELLED V1BackupsResponseOutputBackupsStatus = "CANCELLED" - V1BackupsResponseOutputBackupsStatusCOMPLETED V1BackupsResponseOutputBackupsStatus = "COMPLETED" - V1BackupsResponseOutputBackupsStatusFAILED V1BackupsResponseOutputBackupsStatus = "FAILED" - V1BackupsResponseOutputBackupsStatusPENDING V1BackupsResponseOutputBackupsStatus = "PENDING" - V1BackupsResponseOutputBackupsStatusREMOVED V1BackupsResponseOutputBackupsStatus = "REMOVED" -) - -// Valid indicates whether the value is a known member of the V1BackupsResponseOutputBackupsStatus enum. -func (e V1BackupsResponseOutputBackupsStatus) Valid() bool { - switch e { - case V1BackupsResponseOutputBackupsStatusARCHIVED: - return true - case V1BackupsResponseOutputBackupsStatusCANCELLED: - return true - case V1BackupsResponseOutputBackupsStatusCOMPLETED: - return true - case V1BackupsResponseOutputBackupsStatusFAILED: - return true - case V1BackupsResponseOutputBackupsStatusPENDING: - return true - case V1BackupsResponseOutputBackupsStatusREMOVED: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyDesiredInstanceSize. -const ( - V1CreateProjectBodyDesiredInstanceSizeLarge V1CreateProjectBodyDesiredInstanceSize = "large" - V1CreateProjectBodyDesiredInstanceSizeMedium V1CreateProjectBodyDesiredInstanceSize = "medium" - V1CreateProjectBodyDesiredInstanceSizeMicro V1CreateProjectBodyDesiredInstanceSize = "micro" - V1CreateProjectBodyDesiredInstanceSizeN12xlarge V1CreateProjectBodyDesiredInstanceSize = "12xlarge" - V1CreateProjectBodyDesiredInstanceSizeN16xlarge V1CreateProjectBodyDesiredInstanceSize = "16xlarge" - V1CreateProjectBodyDesiredInstanceSizeN24xlarge V1CreateProjectBodyDesiredInstanceSize = "24xlarge" - V1CreateProjectBodyDesiredInstanceSizeN24xlargeHighMemory V1CreateProjectBodyDesiredInstanceSize = "24xlarge_high_memory" - V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedCpu V1CreateProjectBodyDesiredInstanceSize = "24xlarge_optimized_cpu" - V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedMemory V1CreateProjectBodyDesiredInstanceSize = "24xlarge_optimized_memory" - V1CreateProjectBodyDesiredInstanceSizeN2xlarge V1CreateProjectBodyDesiredInstanceSize = "2xlarge" - V1CreateProjectBodyDesiredInstanceSizeN48xlarge V1CreateProjectBodyDesiredInstanceSize = "48xlarge" - V1CreateProjectBodyDesiredInstanceSizeN48xlargeHighMemory V1CreateProjectBodyDesiredInstanceSize = "48xlarge_high_memory" - V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedCpu V1CreateProjectBodyDesiredInstanceSize = "48xlarge_optimized_cpu" - V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedMemory V1CreateProjectBodyDesiredInstanceSize = "48xlarge_optimized_memory" - V1CreateProjectBodyDesiredInstanceSizeN4xlarge V1CreateProjectBodyDesiredInstanceSize = "4xlarge" - V1CreateProjectBodyDesiredInstanceSizeN8xlarge V1CreateProjectBodyDesiredInstanceSize = "8xlarge" - V1CreateProjectBodyDesiredInstanceSizeNano V1CreateProjectBodyDesiredInstanceSize = "nano" - V1CreateProjectBodyDesiredInstanceSizeSmall V1CreateProjectBodyDesiredInstanceSize = "small" - V1CreateProjectBodyDesiredInstanceSizeXlarge V1CreateProjectBodyDesiredInstanceSize = "xlarge" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyDesiredInstanceSize enum. -func (e V1CreateProjectBodyDesiredInstanceSize) Valid() bool { - switch e { - case V1CreateProjectBodyDesiredInstanceSizeLarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeMedium: - return true - case V1CreateProjectBodyDesiredInstanceSizeMicro: - return true - case V1CreateProjectBodyDesiredInstanceSizeN12xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN16xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN24xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN24xlargeHighMemory: - return true - case V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedCpu: - return true - case V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedMemory: - return true - case V1CreateProjectBodyDesiredInstanceSizeN2xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN48xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN48xlargeHighMemory: - return true - case V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedCpu: - return true - case V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedMemory: - return true - case V1CreateProjectBodyDesiredInstanceSizeN4xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeN8xlarge: - return true - case V1CreateProjectBodyDesiredInstanceSizeNano: - return true - case V1CreateProjectBodyDesiredInstanceSizeSmall: - return true - case V1CreateProjectBodyDesiredInstanceSizeXlarge: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyPlan. -const ( - V1CreateProjectBodyPlanFree V1CreateProjectBodyPlan = "free" - V1CreateProjectBodyPlanPro V1CreateProjectBodyPlan = "pro" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyPlan enum. -func (e V1CreateProjectBodyPlan) Valid() bool { - switch e { - case V1CreateProjectBodyPlanFree: - return true - case V1CreateProjectBodyPlanPro: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyRegion. -const ( - V1CreateProjectBodyRegionApEast1 V1CreateProjectBodyRegion = "ap-east-1" - V1CreateProjectBodyRegionApNortheast1 V1CreateProjectBodyRegion = "ap-northeast-1" - V1CreateProjectBodyRegionApNortheast2 V1CreateProjectBodyRegion = "ap-northeast-2" - V1CreateProjectBodyRegionApSouth1 V1CreateProjectBodyRegion = "ap-south-1" - V1CreateProjectBodyRegionApSoutheast1 V1CreateProjectBodyRegion = "ap-southeast-1" - V1CreateProjectBodyRegionApSoutheast2 V1CreateProjectBodyRegion = "ap-southeast-2" - V1CreateProjectBodyRegionCaCentral1 V1CreateProjectBodyRegion = "ca-central-1" - V1CreateProjectBodyRegionEuCentral1 V1CreateProjectBodyRegion = "eu-central-1" - V1CreateProjectBodyRegionEuCentral2 V1CreateProjectBodyRegion = "eu-central-2" - V1CreateProjectBodyRegionEuNorth1 V1CreateProjectBodyRegion = "eu-north-1" - V1CreateProjectBodyRegionEuWest1 V1CreateProjectBodyRegion = "eu-west-1" - V1CreateProjectBodyRegionEuWest2 V1CreateProjectBodyRegion = "eu-west-2" - V1CreateProjectBodyRegionEuWest3 V1CreateProjectBodyRegion = "eu-west-3" - V1CreateProjectBodyRegionSaEast1 V1CreateProjectBodyRegion = "sa-east-1" - V1CreateProjectBodyRegionUsEast1 V1CreateProjectBodyRegion = "us-east-1" - V1CreateProjectBodyRegionUsEast2 V1CreateProjectBodyRegion = "us-east-2" - V1CreateProjectBodyRegionUsWest1 V1CreateProjectBodyRegion = "us-west-1" - V1CreateProjectBodyRegionUsWest2 V1CreateProjectBodyRegion = "us-west-2" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyRegion enum. -func (e V1CreateProjectBodyRegion) Valid() bool { - switch e { - case V1CreateProjectBodyRegionApEast1: - return true - case V1CreateProjectBodyRegionApNortheast1: - return true - case V1CreateProjectBodyRegionApNortheast2: - return true - case V1CreateProjectBodyRegionApSouth1: - return true - case V1CreateProjectBodyRegionApSoutheast1: - return true - case V1CreateProjectBodyRegionApSoutheast2: - return true - case V1CreateProjectBodyRegionCaCentral1: - return true - case V1CreateProjectBodyRegionEuCentral1: - return true - case V1CreateProjectBodyRegionEuCentral2: - return true - case V1CreateProjectBodyRegionEuNorth1: - return true - case V1CreateProjectBodyRegionEuWest1: - return true - case V1CreateProjectBodyRegionEuWest2: - return true - case V1CreateProjectBodyRegionEuWest3: - return true - case V1CreateProjectBodyRegionSaEast1: - return true - case V1CreateProjectBodyRegionUsEast1: - return true - case V1CreateProjectBodyRegionUsEast2: - return true - case V1CreateProjectBodyRegionUsWest1: - return true - case V1CreateProjectBodyRegionUsWest2: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyRegionSelection0Code. -const ( - ApEast1 V1CreateProjectBodyRegionSelection0Code = "ap-east-1" - ApNortheast1 V1CreateProjectBodyRegionSelection0Code = "ap-northeast-1" - ApNortheast2 V1CreateProjectBodyRegionSelection0Code = "ap-northeast-2" - ApSouth1 V1CreateProjectBodyRegionSelection0Code = "ap-south-1" - ApSoutheast1 V1CreateProjectBodyRegionSelection0Code = "ap-southeast-1" - ApSoutheast2 V1CreateProjectBodyRegionSelection0Code = "ap-southeast-2" - CaCentral1 V1CreateProjectBodyRegionSelection0Code = "ca-central-1" - EuCentral1 V1CreateProjectBodyRegionSelection0Code = "eu-central-1" - EuCentral2 V1CreateProjectBodyRegionSelection0Code = "eu-central-2" - EuNorth1 V1CreateProjectBodyRegionSelection0Code = "eu-north-1" - EuWest1 V1CreateProjectBodyRegionSelection0Code = "eu-west-1" - EuWest2 V1CreateProjectBodyRegionSelection0Code = "eu-west-2" - EuWest3 V1CreateProjectBodyRegionSelection0Code = "eu-west-3" - SaEast1 V1CreateProjectBodyRegionSelection0Code = "sa-east-1" - UsEast1 V1CreateProjectBodyRegionSelection0Code = "us-east-1" - UsEast2 V1CreateProjectBodyRegionSelection0Code = "us-east-2" - UsWest1 V1CreateProjectBodyRegionSelection0Code = "us-west-1" - UsWest2 V1CreateProjectBodyRegionSelection0Code = "us-west-2" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyRegionSelection0Code enum. -func (e V1CreateProjectBodyRegionSelection0Code) Valid() bool { - switch e { - case ApEast1: - return true - case ApNortheast1: - return true - case ApNortheast2: - return true - case ApSouth1: - return true - case ApSoutheast1: - return true - case ApSoutheast2: - return true - case CaCentral1: - return true - case EuCentral1: - return true - case EuCentral2: - return true - case EuNorth1: - return true - case EuWest1: - return true - case EuWest2: - return true - case EuWest3: - return true - case SaEast1: - return true - case UsEast1: - return true - case UsEast2: - return true - case UsWest1: - return true - case UsWest2: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyRegionSelection0Type. -const ( - Specific V1CreateProjectBodyRegionSelection0Type = "specific" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyRegionSelection0Type enum. -func (e V1CreateProjectBodyRegionSelection0Type) Valid() bool { - switch e { - case Specific: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyRegionSelection1Code. -const ( - Americas V1CreateProjectBodyRegionSelection1Code = "americas" - Apac V1CreateProjectBodyRegionSelection1Code = "apac" - Emea V1CreateProjectBodyRegionSelection1Code = "emea" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyRegionSelection1Code enum. -func (e V1CreateProjectBodyRegionSelection1Code) Valid() bool { - switch e { - case Americas: - return true - case Apac: - return true - case Emea: - return true - default: - return false - } -} - -// Defines values for V1CreateProjectBodyRegionSelection1Type. -const ( - SmartGroup V1CreateProjectBodyRegionSelection1Type = "smartGroup" -) - -// Valid indicates whether the value is a known member of the V1CreateProjectBodyRegionSelection1Type enum. -func (e V1CreateProjectBodyRegionSelection1Type) Valid() bool { - switch e { - case SmartGroup: - return true - default: - return false - } -} - -// Defines values for V1ListEntitlementsResponseOutputEntitlementsFeatureKey. -const ( - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyApiMembersInvitations V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "api.members.invitations" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyApiMembersRoles V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "api.members.roles" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "assistant.advance_model" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuditLogDrains V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "audit_log_drains" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthAdvancedAuthSettings V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.advanced_auth_settings" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthCustomJwtTemplate V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.custom_jwt_template" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthCustomOauthMaxProviders V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.custom_oauth.max_providers" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthHooks V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.hooks" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthLeakedPasswordProtection V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.leaked_password_protection" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaEnhancedSecurity V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.mfa_enhanced_security" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaPhone V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.mfa_phone" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaWebAuthn V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.mfa_web_authn" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPasswordHibp V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.password_hibp" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPerformanceSettings V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.performance_settings" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPlatformSso V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.platform.sso" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthSaml2 V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.saml_2" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthUserSessions V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "auth.user_sessions" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupRestoreToNewProject V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "backup.restore_to_new_project" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupRetentionDays V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "backup.retention_days" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupSchedule V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "backup.schedule" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBranchingLimit V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "branching_limit" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBranchingPersistent V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "branching_persistent" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyCustomDomain V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "custom_domain" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyDedicatedPooler V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "dedicated_pooler" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyFunctionMaxCount V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "function.max_count" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyFunctionSizeLimitMb V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "function.size_limit_mb" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesComputeUpdateAvailableSizes V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "instances.compute_update_available_sizes" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesDiskModifications V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "instances.disk_modifications" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesHighAvailability V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "instances.high_availability" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesOrioledb V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "instances.orioledb" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesReadReplicas V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "instances.read_replicas" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIntegrationsGithubConnections V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "integrations.github_connections" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "integrations.github_push_webhooks_limit" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIpv4 V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "ipv4" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyLogDrains V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "log_drains" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyLogRetentionDays V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "log.retention_days" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyObservabilityDashboardAdvancedMetrics V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "observability.dashboard_advanced_metrics" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyPitrAvailableVariants V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "pitr.available_variants" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectCloning V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "project_cloning" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectPausing V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "project_pausing" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectRestoreAfterExpiry V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "project_restore_after_expiry" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectScopedRoles V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "project_scoped_roles" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxBytesPerSecond V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_bytes_per_second" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxChannelsPerClient V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_channels_per_client" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxConcurrentUsers V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_concurrent_users" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxEventsPerSecond V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_events_per_second" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxJoinsPerSecond V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_joins_per_second" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxPayloadSizeInKb V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_payload_size_in_kb" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxPresenceEventsPerSecond V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "realtime.max_presence_events_per_second" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyReplicationEtl V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "replication.etl" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityAuditLogsDays V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.audit_logs_days" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityEnforceMfa V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.enforce_mfa" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityIso27001Certificate V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.iso27001_certificate" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityMemberRoles V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.member_roles" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityPrivateLink V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.private_link" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityQuestionnaire V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.questionnaire" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecuritySoc2Report V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "security.soc2_report" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageIcebergCatalog V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.iceberg_catalog" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageImageTransformations V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.image_transformations" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageMaxFileSize V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.max_file_size" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageMaxFileSizeConfigurable V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.max_file_size.configurable" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStoragePurgeCache V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.purge_cache" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageVectorBuckets V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "storage.vector_buckets" - V1ListEntitlementsResponseOutputEntitlementsFeatureKeyVanitySubdomain V1ListEntitlementsResponseOutputEntitlementsFeatureKey = "vanity_subdomain" -) - -// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseOutputEntitlementsFeatureKey enum. -func (e V1ListEntitlementsResponseOutputEntitlementsFeatureKey) Valid() bool { - switch e { - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyApiMembersInvitations: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyApiMembersRoles: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAssistantAdvanceModel: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuditLogDrains: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthAdvancedAuthSettings: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthCustomJwtTemplate: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthCustomOauthMaxProviders: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthHooks: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthLeakedPasswordProtection: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaEnhancedSecurity: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaPhone: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthMfaWebAuthn: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPasswordHibp: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPerformanceSettings: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthPlatformSso: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthSaml2: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyAuthUserSessions: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupRestoreToNewProject: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupRetentionDays: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBackupSchedule: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBranchingLimit: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyBranchingPersistent: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyCustomDomain: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyDedicatedPooler: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyFunctionMaxCount: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyFunctionSizeLimitMb: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesComputeUpdateAvailableSizes: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesDiskModifications: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesHighAvailability: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesOrioledb: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyInstancesReadReplicas: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIntegrationsGithubConnections: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyIpv4: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyLogDrains: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyLogRetentionDays: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyObservabilityDashboardAdvancedMetrics: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyPitrAvailableVariants: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectCloning: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectPausing: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectRestoreAfterExpiry: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyProjectScopedRoles: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxBytesPerSecond: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxChannelsPerClient: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxConcurrentUsers: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxEventsPerSecond: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxJoinsPerSecond: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxPayloadSizeInKb: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyRealtimeMaxPresenceEventsPerSecond: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyReplicationEtl: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityAuditLogsDays: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityEnforceMfa: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityIso27001Certificate: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityMemberRoles: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityPrivateLink: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecurityQuestionnaire: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeySecuritySoc2Report: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageIcebergCatalog: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageImageTransformations: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageMaxFileSize: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageMaxFileSizeConfigurable: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStoragePurgeCache: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyStorageVectorBuckets: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureKeyVanitySubdomain: - return true - default: - return false - } -} - -// Defines values for V1ListEntitlementsResponseOutputEntitlementsFeatureType. -const ( - V1ListEntitlementsResponseOutputEntitlementsFeatureTypeBoolean V1ListEntitlementsResponseOutputEntitlementsFeatureType = "boolean" - V1ListEntitlementsResponseOutputEntitlementsFeatureTypeNumeric V1ListEntitlementsResponseOutputEntitlementsFeatureType = "numeric" - V1ListEntitlementsResponseOutputEntitlementsFeatureTypeSet V1ListEntitlementsResponseOutputEntitlementsFeatureType = "set" -) - -// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseOutputEntitlementsFeatureType enum. -func (e V1ListEntitlementsResponseOutputEntitlementsFeatureType) Valid() bool { - switch e { - case V1ListEntitlementsResponseOutputEntitlementsFeatureTypeBoolean: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureTypeNumeric: - return true - case V1ListEntitlementsResponseOutputEntitlementsFeatureTypeSet: - return true - default: - return false - } -} - -// Defines values for V1ListEntitlementsResponseOutputEntitlementsType. -const ( - V1ListEntitlementsResponseOutputEntitlementsTypeBoolean V1ListEntitlementsResponseOutputEntitlementsType = "boolean" - V1ListEntitlementsResponseOutputEntitlementsTypeNumeric V1ListEntitlementsResponseOutputEntitlementsType = "numeric" - V1ListEntitlementsResponseOutputEntitlementsTypeSet V1ListEntitlementsResponseOutputEntitlementsType = "set" -) - -// Valid indicates whether the value is a known member of the V1ListEntitlementsResponseOutputEntitlementsType enum. -func (e V1ListEntitlementsResponseOutputEntitlementsType) Valid() bool { - switch e { - case V1ListEntitlementsResponseOutputEntitlementsTypeBoolean: - return true - case V1ListEntitlementsResponseOutputEntitlementsTypeNumeric: - return true - case V1ListEntitlementsResponseOutputEntitlementsTypeSet: - return true - default: - return false - } -} - -// Defines values for V1OrganizationSlugResponseOutputAllowedReleaseChannels. -const ( - V1OrganizationSlugResponseOutputAllowedReleaseChannelsAlpha V1OrganizationSlugResponseOutputAllowedReleaseChannels = "alpha" - V1OrganizationSlugResponseOutputAllowedReleaseChannelsBeta V1OrganizationSlugResponseOutputAllowedReleaseChannels = "beta" - V1OrganizationSlugResponseOutputAllowedReleaseChannelsGa V1OrganizationSlugResponseOutputAllowedReleaseChannels = "ga" - V1OrganizationSlugResponseOutputAllowedReleaseChannelsInternal V1OrganizationSlugResponseOutputAllowedReleaseChannels = "internal" - V1OrganizationSlugResponseOutputAllowedReleaseChannelsPreview V1OrganizationSlugResponseOutputAllowedReleaseChannels = "preview" - V1OrganizationSlugResponseOutputAllowedReleaseChannelsWithdrawn V1OrganizationSlugResponseOutputAllowedReleaseChannels = "withdrawn" -) - -// Valid indicates whether the value is a known member of the V1OrganizationSlugResponseOutputAllowedReleaseChannels enum. -func (e V1OrganizationSlugResponseOutputAllowedReleaseChannels) Valid() bool { - switch e { - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsAlpha: - return true - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsBeta: - return true - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsGa: - return true - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsInternal: - return true - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsPreview: - return true - case V1OrganizationSlugResponseOutputAllowedReleaseChannelsWithdrawn: - return true - default: - return false - } -} - -// Defines values for V1OrganizationSlugResponseOutputPlan. -const ( - V1OrganizationSlugResponseOutputPlanEnterprise V1OrganizationSlugResponseOutputPlan = "enterprise" - V1OrganizationSlugResponseOutputPlanFree V1OrganizationSlugResponseOutputPlan = "free" - V1OrganizationSlugResponseOutputPlanPlatform V1OrganizationSlugResponseOutputPlan = "platform" - V1OrganizationSlugResponseOutputPlanPro V1OrganizationSlugResponseOutputPlan = "pro" - V1OrganizationSlugResponseOutputPlanTeam V1OrganizationSlugResponseOutputPlan = "team" -) - -// Valid indicates whether the value is a known member of the V1OrganizationSlugResponseOutputPlan enum. -func (e V1OrganizationSlugResponseOutputPlan) Valid() bool { - switch e { - case V1OrganizationSlugResponseOutputPlanEnterprise: - return true - case V1OrganizationSlugResponseOutputPlanFree: - return true - case V1OrganizationSlugResponseOutputPlanPlatform: - return true - case V1OrganizationSlugResponseOutputPlanPro: - return true - case V1OrganizationSlugResponseOutputPlanTeam: - return true - default: - return false - } -} - -// Defines values for V1PgbouncerConfigResponseOutputPoolMode. -const ( - Session V1PgbouncerConfigResponseOutputPoolMode = "session" - Statement V1PgbouncerConfigResponseOutputPoolMode = "statement" - Transaction V1PgbouncerConfigResponseOutputPoolMode = "transaction" -) - -// Valid indicates whether the value is a known member of the V1PgbouncerConfigResponseOutputPoolMode enum. -func (e V1PgbouncerConfigResponseOutputPoolMode) Valid() bool { - switch e { - case Session: - return true - case Statement: - return true - case Transaction: - return true - default: - return false - } -} - -// Defines values for V1ProjectAdvisorsResponseOutputLintsCategories. -const ( - HEALTH V1ProjectAdvisorsResponseOutputLintsCategories = "HEALTH" - PERFORMANCE V1ProjectAdvisorsResponseOutputLintsCategories = "PERFORMANCE" - SECURITY V1ProjectAdvisorsResponseOutputLintsCategories = "SECURITY" -) - -// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseOutputLintsCategories enum. -func (e V1ProjectAdvisorsResponseOutputLintsCategories) Valid() bool { - switch e { - case HEALTH: - return true - case PERFORMANCE: - return true - case SECURITY: - return true - default: - return false - } -} - -// Defines values for V1ProjectAdvisorsResponseOutputLintsFacing. -const ( - EXTERNAL V1ProjectAdvisorsResponseOutputLintsFacing = "EXTERNAL" -) - -// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseOutputLintsFacing enum. -func (e V1ProjectAdvisorsResponseOutputLintsFacing) Valid() bool { - switch e { - case EXTERNAL: - return true - default: - return false - } -} - -// Defines values for V1ProjectAdvisorsResponseOutputLintsLevel. -const ( - ERROR V1ProjectAdvisorsResponseOutputLintsLevel = "ERROR" - INFO V1ProjectAdvisorsResponseOutputLintsLevel = "INFO" - WARN V1ProjectAdvisorsResponseOutputLintsLevel = "WARN" -) - -// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseOutputLintsLevel enum. -func (e V1ProjectAdvisorsResponseOutputLintsLevel) Valid() bool { - switch e { - case ERROR: - return true - case INFO: - return true - case WARN: - return true - default: - return false - } -} - -// Defines values for V1ProjectAdvisorsResponseOutputLintsMetadataType. -const ( - V1ProjectAdvisorsResponseOutputLintsMetadataTypeAuth V1ProjectAdvisorsResponseOutputLintsMetadataType = "auth" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeCompliance V1ProjectAdvisorsResponseOutputLintsMetadataType = "compliance" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeExtension V1ProjectAdvisorsResponseOutputLintsMetadataType = "extension" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeForeignTable V1ProjectAdvisorsResponseOutputLintsMetadataType = "foreign table" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeFunction V1ProjectAdvisorsResponseOutputLintsMetadataType = "function" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeHealth V1ProjectAdvisorsResponseOutputLintsMetadataType = "health" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeMaterializedView V1ProjectAdvisorsResponseOutputLintsMetadataType = "materialized view" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeTable V1ProjectAdvisorsResponseOutputLintsMetadataType = "table" - V1ProjectAdvisorsResponseOutputLintsMetadataTypeView V1ProjectAdvisorsResponseOutputLintsMetadataType = "view" -) - -// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseOutputLintsMetadataType enum. -func (e V1ProjectAdvisorsResponseOutputLintsMetadataType) Valid() bool { - switch e { - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeAuth: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeCompliance: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeExtension: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeForeignTable: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeFunction: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeHealth: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeMaterializedView: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeTable: - return true - case V1ProjectAdvisorsResponseOutputLintsMetadataTypeView: - return true - default: - return false - } -} - -// Defines values for V1ProjectAdvisorsResponseOutputLintsName. -const ( - AdvisorCheckUnavailable V1ProjectAdvisorsResponseOutputLintsName = "advisor_check_unavailable" - AuthInsufficientMfaOptions V1ProjectAdvisorsResponseOutputLintsName = "auth_insufficient_mfa_options" - AuthLeakedPasswordProtection V1ProjectAdvisorsResponseOutputLintsName = "auth_leaked_password_protection" - AuthOtpLongExpiry V1ProjectAdvisorsResponseOutputLintsName = "auth_otp_long_expiry" - AuthOtpShortLength V1ProjectAdvisorsResponseOutputLintsName = "auth_otp_short_length" - AuthPasswordPolicyMissing V1ProjectAdvisorsResponseOutputLintsName = "auth_password_policy_missing" - AuthRlsInitplan V1ProjectAdvisorsResponseOutputLintsName = "auth_rls_initplan" - AuthUsersExposed V1ProjectAdvisorsResponseOutputLintsName = "auth_users_exposed" - DbConnectionFailing V1ProjectAdvisorsResponseOutputLintsName = "db_connection_failing" - DbConnectionLimitReached V1ProjectAdvisorsResponseOutputLintsName = "db_connection_limit_reached" - DbNotReachable V1ProjectAdvisorsResponseOutputLintsName = "db_not_reachable" - DuplicateIndex V1ProjectAdvisorsResponseOutputLintsName = "duplicate_index" - ExtensionInPublic V1ProjectAdvisorsResponseOutputLintsName = "extension_in_public" - ForeignTableInApi V1ProjectAdvisorsResponseOutputLintsName = "foreign_table_in_api" - FunctionSearchPathMutable V1ProjectAdvisorsResponseOutputLintsName = "function_search_path_mutable" - InstanceAlertFiring V1ProjectAdvisorsResponseOutputLintsName = "instance_alert_firing" - InstanceDbDown V1ProjectAdvisorsResponseOutputLintsName = "instance_db_down" - InstanceTelemetryLost V1ProjectAdvisorsResponseOutputLintsName = "instance_telemetry_lost" - LeakedServiceKey V1ProjectAdvisorsResponseOutputLintsName = "leaked_service_key" - LogAuthErrorRateHigh V1ProjectAdvisorsResponseOutputLintsName = "log_auth_error_rate_high" - LogConnectionsNotEnabled V1ProjectAdvisorsResponseOutputLintsName = "log_connections_not_enabled" - LogDataApiErrorRateHigh V1ProjectAdvisorsResponseOutputLintsName = "log_data_api_error_rate_high" - LogEdgeFunctionErrorRateHigh V1ProjectAdvisorsResponseOutputLintsName = "log_edge_function_error_rate_high" - LogStorageErrorRateHigh V1ProjectAdvisorsResponseOutputLintsName = "log_storage_error_rate_high" - MaterializedViewInApi V1ProjectAdvisorsResponseOutputLintsName = "materialized_view_in_api" - MultiplePermissivePolicies V1ProjectAdvisorsResponseOutputLintsName = "multiple_permissive_policies" - NetworkRestrictionsNotSet V1ProjectAdvisorsResponseOutputLintsName = "network_restrictions_not_set" - NoBackupAdmin V1ProjectAdvisorsResponseOutputLintsName = "no_backup_admin" - NoPrimaryKey V1ProjectAdvisorsResponseOutputLintsName = "no_primary_key" - PasswordRequirementsMinLength V1ProjectAdvisorsResponseOutputLintsName = "password_requirements_min_length" - PitrNotEnabled V1ProjectAdvisorsResponseOutputLintsName = "pitr_not_enabled" - PolicyExistsRlsDisabled V1ProjectAdvisorsResponseOutputLintsName = "policy_exists_rls_disabled" - ProjectNotActive V1ProjectAdvisorsResponseOutputLintsName = "project_not_active" - RealtimeAuthorizationTimeouts V1ProjectAdvisorsResponseOutputLintsName = "realtime_authorization_timeouts" - RealtimePostgresChangesSpike V1ProjectAdvisorsResponseOutputLintsName = "realtime_postgres_changes_spike" - RlsDisabledInPublic V1ProjectAdvisorsResponseOutputLintsName = "rls_disabled_in_public" - RlsEnabledNoPolicy V1ProjectAdvisorsResponseOutputLintsName = "rls_enabled_no_policy" - RlsReferencesUserMetadata V1ProjectAdvisorsResponseOutputLintsName = "rls_references_user_metadata" - SecurityDefinerView V1ProjectAdvisorsResponseOutputLintsName = "security_definer_view" - SslNotEnforced V1ProjectAdvisorsResponseOutputLintsName = "ssl_not_enforced" - UnindexedForeignKeys V1ProjectAdvisorsResponseOutputLintsName = "unindexed_foreign_keys" - UnsupportedRegTypes V1ProjectAdvisorsResponseOutputLintsName = "unsupported_reg_types" - UnusedIndex V1ProjectAdvisorsResponseOutputLintsName = "unused_index" - VulnerablePostgresVersion V1ProjectAdvisorsResponseOutputLintsName = "vulnerable_postgres_version" -) - -// Valid indicates whether the value is a known member of the V1ProjectAdvisorsResponseOutputLintsName enum. -func (e V1ProjectAdvisorsResponseOutputLintsName) Valid() bool { - switch e { - case AdvisorCheckUnavailable: - return true - case AuthInsufficientMfaOptions: - return true - case AuthLeakedPasswordProtection: - return true - case AuthOtpLongExpiry: - return true - case AuthOtpShortLength: - return true - case AuthPasswordPolicyMissing: - return true - case AuthRlsInitplan: - return true - case AuthUsersExposed: - return true - case DbConnectionFailing: - return true - case DbConnectionLimitReached: - return true - case DbNotReachable: - return true - case DuplicateIndex: - return true - case ExtensionInPublic: - return true - case ForeignTableInApi: - return true - case FunctionSearchPathMutable: - return true - case InstanceAlertFiring: - return true - case InstanceDbDown: - return true - case InstanceTelemetryLost: - return true - case LeakedServiceKey: - return true - case LogAuthErrorRateHigh: - return true - case LogConnectionsNotEnabled: - return true - case LogDataApiErrorRateHigh: - return true - case LogEdgeFunctionErrorRateHigh: - return true - case LogStorageErrorRateHigh: - return true - case MaterializedViewInApi: - return true - case MultiplePermissivePolicies: - return true - case NetworkRestrictionsNotSet: - return true - case NoBackupAdmin: - return true - case NoPrimaryKey: - return true - case PasswordRequirementsMinLength: - return true - case PitrNotEnabled: - return true - case PolicyExistsRlsDisabled: - return true - case ProjectNotActive: - return true - case RealtimeAuthorizationTimeouts: - return true - case RealtimePostgresChangesSpike: - return true - case RlsDisabledInPublic: - return true - case RlsEnabledNoPolicy: - return true - case RlsReferencesUserMetadata: - return true - case SecurityDefinerView: - return true - case SslNotEnforced: - return true - case UnindexedForeignKeys: - return true - case UnsupportedRegTypes: - return true - case UnusedIndex: - return true - case VulnerablePostgresVersion: - return true - default: - return false - } -} - -// Defines values for V1ProjectResponseOutputStatus. -const ( - V1ProjectResponseOutputStatusACTIVEHEALTHY V1ProjectResponseOutputStatus = "ACTIVE_HEALTHY" - V1ProjectResponseOutputStatusACTIVEUNHEALTHY V1ProjectResponseOutputStatus = "ACTIVE_UNHEALTHY" - V1ProjectResponseOutputStatusCOMINGUP V1ProjectResponseOutputStatus = "COMING_UP" - V1ProjectResponseOutputStatusGOINGDOWN V1ProjectResponseOutputStatus = "GOING_DOWN" - V1ProjectResponseOutputStatusINACTIVE V1ProjectResponseOutputStatus = "INACTIVE" - V1ProjectResponseOutputStatusINITFAILED V1ProjectResponseOutputStatus = "INIT_FAILED" - V1ProjectResponseOutputStatusPAUSEFAILED V1ProjectResponseOutputStatus = "PAUSE_FAILED" - V1ProjectResponseOutputStatusPAUSING V1ProjectResponseOutputStatus = "PAUSING" - V1ProjectResponseOutputStatusREMOVED V1ProjectResponseOutputStatus = "REMOVED" - V1ProjectResponseOutputStatusRESIZING V1ProjectResponseOutputStatus = "RESIZING" - V1ProjectResponseOutputStatusRESTARTING V1ProjectResponseOutputStatus = "RESTARTING" - V1ProjectResponseOutputStatusRESTOREFAILED V1ProjectResponseOutputStatus = "RESTORE_FAILED" - V1ProjectResponseOutputStatusRESTORING V1ProjectResponseOutputStatus = "RESTORING" - V1ProjectResponseOutputStatusUNKNOWN V1ProjectResponseOutputStatus = "UNKNOWN" - V1ProjectResponseOutputStatusUPGRADING V1ProjectResponseOutputStatus = "UPGRADING" -) - -// Valid indicates whether the value is a known member of the V1ProjectResponseOutputStatus enum. -func (e V1ProjectResponseOutputStatus) Valid() bool { - switch e { - case V1ProjectResponseOutputStatusACTIVEHEALTHY: - return true - case V1ProjectResponseOutputStatusACTIVEUNHEALTHY: - return true - case V1ProjectResponseOutputStatusCOMINGUP: - return true - case V1ProjectResponseOutputStatusGOINGDOWN: - return true - case V1ProjectResponseOutputStatusINACTIVE: - return true - case V1ProjectResponseOutputStatusINITFAILED: - return true - case V1ProjectResponseOutputStatusPAUSEFAILED: - return true - case V1ProjectResponseOutputStatusPAUSING: - return true - case V1ProjectResponseOutputStatusREMOVED: - return true - case V1ProjectResponseOutputStatusRESIZING: - return true - case V1ProjectResponseOutputStatusRESTARTING: - return true - case V1ProjectResponseOutputStatusRESTOREFAILED: - return true - case V1ProjectResponseOutputStatusRESTORING: - return true - case V1ProjectResponseOutputStatusUNKNOWN: - return true - case V1ProjectResponseOutputStatusUPGRADING: - return true - default: - return false - } -} - -// Defines values for V1ProjectWithDatabaseResponseOutputStatus. -const ( - V1ProjectWithDatabaseResponseOutputStatusACTIVEHEALTHY V1ProjectWithDatabaseResponseOutputStatus = "ACTIVE_HEALTHY" - V1ProjectWithDatabaseResponseOutputStatusACTIVEUNHEALTHY V1ProjectWithDatabaseResponseOutputStatus = "ACTIVE_UNHEALTHY" - V1ProjectWithDatabaseResponseOutputStatusCOMINGUP V1ProjectWithDatabaseResponseOutputStatus = "COMING_UP" - V1ProjectWithDatabaseResponseOutputStatusGOINGDOWN V1ProjectWithDatabaseResponseOutputStatus = "GOING_DOWN" - V1ProjectWithDatabaseResponseOutputStatusINACTIVE V1ProjectWithDatabaseResponseOutputStatus = "INACTIVE" - V1ProjectWithDatabaseResponseOutputStatusINITFAILED V1ProjectWithDatabaseResponseOutputStatus = "INIT_FAILED" - V1ProjectWithDatabaseResponseOutputStatusPAUSEFAILED V1ProjectWithDatabaseResponseOutputStatus = "PAUSE_FAILED" - V1ProjectWithDatabaseResponseOutputStatusPAUSING V1ProjectWithDatabaseResponseOutputStatus = "PAUSING" - V1ProjectWithDatabaseResponseOutputStatusREMOVED V1ProjectWithDatabaseResponseOutputStatus = "REMOVED" - V1ProjectWithDatabaseResponseOutputStatusRESIZING V1ProjectWithDatabaseResponseOutputStatus = "RESIZING" - V1ProjectWithDatabaseResponseOutputStatusRESTARTING V1ProjectWithDatabaseResponseOutputStatus = "RESTARTING" - V1ProjectWithDatabaseResponseOutputStatusRESTOREFAILED V1ProjectWithDatabaseResponseOutputStatus = "RESTORE_FAILED" - V1ProjectWithDatabaseResponseOutputStatusRESTORING V1ProjectWithDatabaseResponseOutputStatus = "RESTORING" - V1ProjectWithDatabaseResponseOutputStatusUNKNOWN V1ProjectWithDatabaseResponseOutputStatus = "UNKNOWN" - V1ProjectWithDatabaseResponseOutputStatusUPGRADING V1ProjectWithDatabaseResponseOutputStatus = "UPGRADING" -) - -// Valid indicates whether the value is a known member of the V1ProjectWithDatabaseResponseOutputStatus enum. -func (e V1ProjectWithDatabaseResponseOutputStatus) Valid() bool { - switch e { - case V1ProjectWithDatabaseResponseOutputStatusACTIVEHEALTHY: - return true - case V1ProjectWithDatabaseResponseOutputStatusACTIVEUNHEALTHY: - return true - case V1ProjectWithDatabaseResponseOutputStatusCOMINGUP: - return true - case V1ProjectWithDatabaseResponseOutputStatusGOINGDOWN: - return true - case V1ProjectWithDatabaseResponseOutputStatusINACTIVE: - return true - case V1ProjectWithDatabaseResponseOutputStatusINITFAILED: - return true - case V1ProjectWithDatabaseResponseOutputStatusPAUSEFAILED: - return true - case V1ProjectWithDatabaseResponseOutputStatusPAUSING: - return true - case V1ProjectWithDatabaseResponseOutputStatusREMOVED: - return true - case V1ProjectWithDatabaseResponseOutputStatusRESIZING: - return true - case V1ProjectWithDatabaseResponseOutputStatusRESTARTING: - return true - case V1ProjectWithDatabaseResponseOutputStatusRESTOREFAILED: - return true - case V1ProjectWithDatabaseResponseOutputStatusRESTORING: - return true - case V1ProjectWithDatabaseResponseOutputStatusUNKNOWN: - return true - case V1ProjectWithDatabaseResponseOutputStatusUPGRADING: - return true - default: - return false - } -} - -// Defines values for V1RestorePointResponseStatus. -const ( - V1RestorePointResponseStatusAVAILABLE V1RestorePointResponseStatus = "AVAILABLE" - V1RestorePointResponseStatusFAILED V1RestorePointResponseStatus = "FAILED" - V1RestorePointResponseStatusPENDING V1RestorePointResponseStatus = "PENDING" - V1RestorePointResponseStatusREMOVED V1RestorePointResponseStatus = "REMOVED" -) - -// Valid indicates whether the value is a known member of the V1RestorePointResponseStatus enum. -func (e V1RestorePointResponseStatus) Valid() bool { - switch e { - case V1RestorePointResponseStatusAVAILABLE: - return true - case V1RestorePointResponseStatusFAILED: - return true - case V1RestorePointResponseStatusPENDING: - return true - case V1RestorePointResponseStatusREMOVED: - return true - default: - return false - } -} - -// Defines values for V1ServiceHealthResponseOutputInfo0Name. -const ( - GoTrue V1ServiceHealthResponseOutputInfo0Name = "GoTrue" -) - -// Valid indicates whether the value is a known member of the V1ServiceHealthResponseOutputInfo0Name enum. -func (e V1ServiceHealthResponseOutputInfo0Name) Valid() bool { - switch e { - case GoTrue: - return true - default: - return false - } -} - -// Defines values for V1ServiceHealthResponseOutputName. -const ( - V1ServiceHealthResponseOutputNameAuth V1ServiceHealthResponseOutputName = "auth" - V1ServiceHealthResponseOutputNameDb V1ServiceHealthResponseOutputName = "db" - V1ServiceHealthResponseOutputNameDbPostgresUser V1ServiceHealthResponseOutputName = "db_postgres_user" - V1ServiceHealthResponseOutputNamePgBouncer V1ServiceHealthResponseOutputName = "pg_bouncer" - V1ServiceHealthResponseOutputNamePooler V1ServiceHealthResponseOutputName = "pooler" - V1ServiceHealthResponseOutputNameRealtime V1ServiceHealthResponseOutputName = "realtime" - V1ServiceHealthResponseOutputNameRest V1ServiceHealthResponseOutputName = "rest" - V1ServiceHealthResponseOutputNameStorage V1ServiceHealthResponseOutputName = "storage" -) - -// Valid indicates whether the value is a known member of the V1ServiceHealthResponseOutputName enum. -func (e V1ServiceHealthResponseOutputName) Valid() bool { - switch e { - case V1ServiceHealthResponseOutputNameAuth: - return true - case V1ServiceHealthResponseOutputNameDb: - return true - case V1ServiceHealthResponseOutputNameDbPostgresUser: - return true - case V1ServiceHealthResponseOutputNamePgBouncer: - return true - case V1ServiceHealthResponseOutputNamePooler: - return true - case V1ServiceHealthResponseOutputNameRealtime: - return true - case V1ServiceHealthResponseOutputNameRest: - return true - case V1ServiceHealthResponseOutputNameStorage: - return true - default: - return false - } -} - -// Defines values for V1ServiceHealthResponseOutputStatus. -const ( - ACTIVEHEALTHY V1ServiceHealthResponseOutputStatus = "ACTIVE_HEALTHY" - COMINGUP V1ServiceHealthResponseOutputStatus = "COMING_UP" - UNHEALTHY V1ServiceHealthResponseOutputStatus = "UNHEALTHY" -) - -// Valid indicates whether the value is a known member of the V1ServiceHealthResponseOutputStatus enum. -func (e V1ServiceHealthResponseOutputStatus) Valid() bool { - switch e { - case ACTIVEHEALTHY: - return true - case COMINGUP: - return true - case UNHEALTHY: - return true - default: - return false - } -} - -// Defines values for VanitySubdomainConfigResponseOutputStatus. -const ( - Active VanitySubdomainConfigResponseOutputStatus = "active" - CustomDomainUsed VanitySubdomainConfigResponseOutputStatus = "custom-domain-used" - NotUsed VanitySubdomainConfigResponseOutputStatus = "not-used" -) - -// Valid indicates whether the value is a known member of the VanitySubdomainConfigResponseOutputStatus enum. -func (e VanitySubdomainConfigResponseOutputStatus) Valid() bool { - switch e { - case Active: - return true - case CustomDomainUsed: - return true - case NotUsed: - return true - default: - return false - } -} - -// Defines values for V1AuthorizeUserParamsResponseType. -const ( - V1AuthorizeUserParamsResponseTypeCode V1AuthorizeUserParamsResponseType = "code" - V1AuthorizeUserParamsResponseTypeIdTokenToken V1AuthorizeUserParamsResponseType = "id_token token" - V1AuthorizeUserParamsResponseTypeToken V1AuthorizeUserParamsResponseType = "token" -) - -// Valid indicates whether the value is a known member of the V1AuthorizeUserParamsResponseType enum. -func (e V1AuthorizeUserParamsResponseType) Valid() bool { - switch e { - case V1AuthorizeUserParamsResponseTypeCode: - return true - case V1AuthorizeUserParamsResponseTypeIdTokenToken: - return true - case V1AuthorizeUserParamsResponseTypeToken: - return true - default: - return false - } -} - -// Defines values for V1AuthorizeUserParamsCodeChallengeMethod. -const ( - V1AuthorizeUserParamsCodeChallengeMethodPlain V1AuthorizeUserParamsCodeChallengeMethod = "plain" - V1AuthorizeUserParamsCodeChallengeMethodS256 V1AuthorizeUserParamsCodeChallengeMethod = "S256" - V1AuthorizeUserParamsCodeChallengeMethodSha256 V1AuthorizeUserParamsCodeChallengeMethod = "sha256" -) - -// Valid indicates whether the value is a known member of the V1AuthorizeUserParamsCodeChallengeMethod enum. -func (e V1AuthorizeUserParamsCodeChallengeMethod) Valid() bool { - switch e { - case V1AuthorizeUserParamsCodeChallengeMethodPlain: - return true - case V1AuthorizeUserParamsCodeChallengeMethodS256: - return true - case V1AuthorizeUserParamsCodeChallengeMethodSha256: - return true - default: - return false - } -} - -// Defines values for V1OauthAuthorizeProjectClaimParamsResponseType. -const ( - V1OauthAuthorizeProjectClaimParamsResponseTypeCode V1OauthAuthorizeProjectClaimParamsResponseType = "code" - V1OauthAuthorizeProjectClaimParamsResponseTypeIdTokenToken V1OauthAuthorizeProjectClaimParamsResponseType = "id_token token" - V1OauthAuthorizeProjectClaimParamsResponseTypeToken V1OauthAuthorizeProjectClaimParamsResponseType = "token" -) - -// Valid indicates whether the value is a known member of the V1OauthAuthorizeProjectClaimParamsResponseType enum. -func (e V1OauthAuthorizeProjectClaimParamsResponseType) Valid() bool { - switch e { - case V1OauthAuthorizeProjectClaimParamsResponseTypeCode: - return true - case V1OauthAuthorizeProjectClaimParamsResponseTypeIdTokenToken: - return true - case V1OauthAuthorizeProjectClaimParamsResponseTypeToken: - return true - default: - return false - } -} - -// Defines values for V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod. -const ( - V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodPlain V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod = "plain" - V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodS256 V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod = "S256" - V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodSha256 V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod = "sha256" -) - -// Valid indicates whether the value is a known member of the V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod enum. -func (e V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod) Valid() bool { - switch e { - case V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodPlain: - return true - case V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodS256: - return true - case V1OauthAuthorizeProjectClaimParamsCodeChallengeMethodSha256: - return true - default: - return false - } -} - -// Defines values for V1GetAllProjectsForOrganizationParamsSort. -const ( - CreatedAsc V1GetAllProjectsForOrganizationParamsSort = "created_asc" - CreatedDesc V1GetAllProjectsForOrganizationParamsSort = "created_desc" - NameAsc V1GetAllProjectsForOrganizationParamsSort = "name_asc" - NameDesc V1GetAllProjectsForOrganizationParamsSort = "name_desc" -) - -// Valid indicates whether the value is a known member of the V1GetAllProjectsForOrganizationParamsSort enum. -func (e V1GetAllProjectsForOrganizationParamsSort) Valid() bool { - switch e { - case CreatedAsc: - return true - case CreatedDesc: - return true - case NameAsc: - return true - case NameDesc: - return true - default: - return false - } -} - -// Defines values for V1GetAvailableRegionsParamsContinent. -const ( - AF V1GetAvailableRegionsParamsContinent = "AF" - AN V1GetAvailableRegionsParamsContinent = "AN" - AS V1GetAvailableRegionsParamsContinent = "AS" - EU V1GetAvailableRegionsParamsContinent = "EU" - NA V1GetAvailableRegionsParamsContinent = "NA" - OC V1GetAvailableRegionsParamsContinent = "OC" - SA V1GetAvailableRegionsParamsContinent = "SA" -) - -// Valid indicates whether the value is a known member of the V1GetAvailableRegionsParamsContinent enum. -func (e V1GetAvailableRegionsParamsContinent) Valid() bool { - switch e { - case AF: - return true - case AN: - return true - case AS: - return true - case EU: - return true - case NA: - return true - case OC: - return true - case SA: - return true - default: - return false - } -} - -// Defines values for V1GetAvailableRegionsParamsDesiredInstanceSize. -const ( - V1GetAvailableRegionsParamsDesiredInstanceSizeLarge V1GetAvailableRegionsParamsDesiredInstanceSize = "large" - V1GetAvailableRegionsParamsDesiredInstanceSizeMedium V1GetAvailableRegionsParamsDesiredInstanceSize = "medium" - V1GetAvailableRegionsParamsDesiredInstanceSizeMicro V1GetAvailableRegionsParamsDesiredInstanceSize = "micro" - V1GetAvailableRegionsParamsDesiredInstanceSizeN12xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "12xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN16xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "16xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "24xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeHighMemory V1GetAvailableRegionsParamsDesiredInstanceSize = "24xlarge_high_memory" - V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeOptimizedCpu V1GetAvailableRegionsParamsDesiredInstanceSize = "24xlarge_optimized_cpu" - V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeOptimizedMemory V1GetAvailableRegionsParamsDesiredInstanceSize = "24xlarge_optimized_memory" - V1GetAvailableRegionsParamsDesiredInstanceSizeN2xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "2xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "48xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeHighMemory V1GetAvailableRegionsParamsDesiredInstanceSize = "48xlarge_high_memory" - V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeOptimizedCpu V1GetAvailableRegionsParamsDesiredInstanceSize = "48xlarge_optimized_cpu" - V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeOptimizedMemory V1GetAvailableRegionsParamsDesiredInstanceSize = "48xlarge_optimized_memory" - V1GetAvailableRegionsParamsDesiredInstanceSizeN4xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "4xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeN8xlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "8xlarge" - V1GetAvailableRegionsParamsDesiredInstanceSizeNano V1GetAvailableRegionsParamsDesiredInstanceSize = "nano" - V1GetAvailableRegionsParamsDesiredInstanceSizeSmall V1GetAvailableRegionsParamsDesiredInstanceSize = "small" - V1GetAvailableRegionsParamsDesiredInstanceSizeXlarge V1GetAvailableRegionsParamsDesiredInstanceSize = "xlarge" -) - -// Valid indicates whether the value is a known member of the V1GetAvailableRegionsParamsDesiredInstanceSize enum. -func (e V1GetAvailableRegionsParamsDesiredInstanceSize) Valid() bool { - switch e { - case V1GetAvailableRegionsParamsDesiredInstanceSizeLarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeMedium: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeMicro: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN12xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN16xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeHighMemory: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeOptimizedCpu: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN24xlargeOptimizedMemory: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN2xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeHighMemory: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeOptimizedCpu: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN48xlargeOptimizedMemory: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN4xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeN8xlarge: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeNano: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeSmall: - return true - case V1GetAvailableRegionsParamsDesiredInstanceSizeXlarge: - return true - default: - return false - } -} - -// Defines values for V1GetSecurityAdvisorsParamsLintType. -const ( - Sql V1GetSecurityAdvisorsParamsLintType = "sql" -) - -// Valid indicates whether the value is a known member of the V1GetSecurityAdvisorsParamsLintType enum. -func (e V1GetSecurityAdvisorsParamsLintType) Valid() bool { - switch e { - case Sql: - return true - default: - return false - } -} - -// Defines values for V1GetProjectFunctionCombinedStatsParamsInterval. -const ( - V1GetProjectFunctionCombinedStatsParamsIntervalN15min V1GetProjectFunctionCombinedStatsParamsInterval = "15min" - V1GetProjectFunctionCombinedStatsParamsIntervalN1day V1GetProjectFunctionCombinedStatsParamsInterval = "1day" - V1GetProjectFunctionCombinedStatsParamsIntervalN1hr V1GetProjectFunctionCombinedStatsParamsInterval = "1hr" - V1GetProjectFunctionCombinedStatsParamsIntervalN3hr V1GetProjectFunctionCombinedStatsParamsInterval = "3hr" -) - -// Valid indicates whether the value is a known member of the V1GetProjectFunctionCombinedStatsParamsInterval enum. -func (e V1GetProjectFunctionCombinedStatsParamsInterval) Valid() bool { - switch e { - case V1GetProjectFunctionCombinedStatsParamsIntervalN15min: - return true - case V1GetProjectFunctionCombinedStatsParamsIntervalN1day: - return true - case V1GetProjectFunctionCombinedStatsParamsIntervalN1hr: - return true - case V1GetProjectFunctionCombinedStatsParamsIntervalN3hr: - return true - default: - return false - } -} - -// Defines values for V1GetProjectUsageApiCountParamsInterval. -const ( - V1GetProjectUsageApiCountParamsIntervalN15min V1GetProjectUsageApiCountParamsInterval = "15min" - V1GetProjectUsageApiCountParamsIntervalN1day V1GetProjectUsageApiCountParamsInterval = "1day" - V1GetProjectUsageApiCountParamsIntervalN1hr V1GetProjectUsageApiCountParamsInterval = "1hr" - V1GetProjectUsageApiCountParamsIntervalN30min V1GetProjectUsageApiCountParamsInterval = "30min" - V1GetProjectUsageApiCountParamsIntervalN3day V1GetProjectUsageApiCountParamsInterval = "3day" - V1GetProjectUsageApiCountParamsIntervalN3hr V1GetProjectUsageApiCountParamsInterval = "3hr" - V1GetProjectUsageApiCountParamsIntervalN7day V1GetProjectUsageApiCountParamsInterval = "7day" -) - -// Valid indicates whether the value is a known member of the V1GetProjectUsageApiCountParamsInterval enum. -func (e V1GetProjectUsageApiCountParamsInterval) Valid() bool { - switch e { - case V1GetProjectUsageApiCountParamsIntervalN15min: - return true - case V1GetProjectUsageApiCountParamsIntervalN1day: - return true - case V1GetProjectUsageApiCountParamsIntervalN1hr: - return true - case V1GetProjectUsageApiCountParamsIntervalN30min: - return true - case V1GetProjectUsageApiCountParamsIntervalN3day: - return true - case V1GetProjectUsageApiCountParamsIntervalN3hr: - return true - case V1GetProjectUsageApiCountParamsIntervalN7day: - return true - default: - return false - } -} - -// Defines values for V1RemoveProjectAddonParamsAddonVariant0. -const ( - V1RemoveProjectAddonParamsAddonVariant0Ci12xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_12xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci16xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_16xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci24xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_24xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeHighMemory V1RemoveProjectAddonParamsAddonVariant0 = "ci_24xlarge_high_memory" - V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeOptimizedCpu V1RemoveProjectAddonParamsAddonVariant0 = "ci_24xlarge_optimized_cpu" - V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeOptimizedMemory V1RemoveProjectAddonParamsAddonVariant0 = "ci_24xlarge_optimized_memory" - V1RemoveProjectAddonParamsAddonVariant0Ci2xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_2xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci48xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_48xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeHighMemory V1RemoveProjectAddonParamsAddonVariant0 = "ci_48xlarge_high_memory" - V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeOptimizedCpu V1RemoveProjectAddonParamsAddonVariant0 = "ci_48xlarge_optimized_cpu" - V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeOptimizedMemory V1RemoveProjectAddonParamsAddonVariant0 = "ci_48xlarge_optimized_memory" - V1RemoveProjectAddonParamsAddonVariant0Ci4xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_4xlarge" - V1RemoveProjectAddonParamsAddonVariant0Ci8xlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_8xlarge" - V1RemoveProjectAddonParamsAddonVariant0CiLarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_large" - V1RemoveProjectAddonParamsAddonVariant0CiMedium V1RemoveProjectAddonParamsAddonVariant0 = "ci_medium" - V1RemoveProjectAddonParamsAddonVariant0CiMicro V1RemoveProjectAddonParamsAddonVariant0 = "ci_micro" - V1RemoveProjectAddonParamsAddonVariant0CiSmall V1RemoveProjectAddonParamsAddonVariant0 = "ci_small" - V1RemoveProjectAddonParamsAddonVariant0CiXlarge V1RemoveProjectAddonParamsAddonVariant0 = "ci_xlarge" -) - -// Valid indicates whether the value is a known member of the V1RemoveProjectAddonParamsAddonVariant0 enum. -func (e V1RemoveProjectAddonParamsAddonVariant0) Valid() bool { - switch e { - case V1RemoveProjectAddonParamsAddonVariant0Ci12xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci16xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci24xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeHighMemory: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeOptimizedCpu: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci24xlargeOptimizedMemory: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci2xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci48xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeHighMemory: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeOptimizedCpu: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci48xlargeOptimizedMemory: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci4xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0Ci8xlarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0CiLarge: - return true - case V1RemoveProjectAddonParamsAddonVariant0CiMedium: - return true - case V1RemoveProjectAddonParamsAddonVariant0CiMicro: - return true - case V1RemoveProjectAddonParamsAddonVariant0CiSmall: - return true - case V1RemoveProjectAddonParamsAddonVariant0CiXlarge: - return true - default: - return false - } -} - -// Defines values for V1RemoveProjectAddonParamsAddonVariant1. -const ( - V1RemoveProjectAddonParamsAddonVariant1CdDefault V1RemoveProjectAddonParamsAddonVariant1 = "cd_default" -) - -// Valid indicates whether the value is a known member of the V1RemoveProjectAddonParamsAddonVariant1 enum. -func (e V1RemoveProjectAddonParamsAddonVariant1) Valid() bool { - switch e { - case V1RemoveProjectAddonParamsAddonVariant1CdDefault: - return true - default: - return false - } -} - -// Defines values for V1RemoveProjectAddonParamsAddonVariant2. -const ( - V1RemoveProjectAddonParamsAddonVariant2Pitr14 V1RemoveProjectAddonParamsAddonVariant2 = "pitr_14" - V1RemoveProjectAddonParamsAddonVariant2Pitr28 V1RemoveProjectAddonParamsAddonVariant2 = "pitr_28" - V1RemoveProjectAddonParamsAddonVariant2Pitr7 V1RemoveProjectAddonParamsAddonVariant2 = "pitr_7" -) - -// Valid indicates whether the value is a known member of the V1RemoveProjectAddonParamsAddonVariant2 enum. -func (e V1RemoveProjectAddonParamsAddonVariant2) Valid() bool { - switch e { - case V1RemoveProjectAddonParamsAddonVariant2Pitr14: - return true - case V1RemoveProjectAddonParamsAddonVariant2Pitr28: - return true - case V1RemoveProjectAddonParamsAddonVariant2Pitr7: - return true - default: - return false - } -} - -// Defines values for V1RemoveProjectAddonParamsAddonVariant3. -const ( - V1RemoveProjectAddonParamsAddonVariant3Ipv4Default V1RemoveProjectAddonParamsAddonVariant3 = "ipv4_default" -) - -// Valid indicates whether the value is a known member of the V1RemoveProjectAddonParamsAddonVariant3 enum. -func (e V1RemoveProjectAddonParamsAddonVariant3) Valid() bool { - switch e { - case V1RemoveProjectAddonParamsAddonVariant3Ipv4Default: - return true - default: - return false - } -} - -// Defines values for V1GetServicesHealthParamsServices. -const ( - V1GetServicesHealthParamsServicesAuth V1GetServicesHealthParamsServices = "auth" - V1GetServicesHealthParamsServicesDb V1GetServicesHealthParamsServices = "db" - V1GetServicesHealthParamsServicesDbPostgresUser V1GetServicesHealthParamsServices = "db_postgres_user" - V1GetServicesHealthParamsServicesPgBouncer V1GetServicesHealthParamsServices = "pg_bouncer" - V1GetServicesHealthParamsServicesPooler V1GetServicesHealthParamsServices = "pooler" - V1GetServicesHealthParamsServicesRealtime V1GetServicesHealthParamsServices = "realtime" - V1GetServicesHealthParamsServicesRest V1GetServicesHealthParamsServices = "rest" - V1GetServicesHealthParamsServicesStorage V1GetServicesHealthParamsServices = "storage" -) - -// Valid indicates whether the value is a known member of the V1GetServicesHealthParamsServices enum. -func (e V1GetServicesHealthParamsServices) Valid() bool { - switch e { - case V1GetServicesHealthParamsServicesAuth: - return true - case V1GetServicesHealthParamsServicesDb: - return true - case V1GetServicesHealthParamsServicesDbPostgresUser: - return true - case V1GetServicesHealthParamsServicesPgBouncer: - return true - case V1GetServicesHealthParamsServicesPooler: - return true - case V1GetServicesHealthParamsServicesRealtime: - return true - case V1GetServicesHealthParamsServicesRest: - return true - case V1GetServicesHealthParamsServicesStorage: - return true - default: - return false - } -} - -// Defines values for V1GetJitAccessConfig200JSONResponseBody0State. -const ( - V1GetJitAccessConfig200JSONResponseBody0StateDisabled V1GetJitAccessConfig200JSONResponseBody0State = "disabled" - V1GetJitAccessConfig200JSONResponseBody0StateEnabled V1GetJitAccessConfig200JSONResponseBody0State = "enabled" -) - -// Valid indicates whether the value is a known member of the V1GetJitAccessConfig200JSONResponseBody0State enum. -func (e V1GetJitAccessConfig200JSONResponseBody0State) Valid() bool { - switch e { - case V1GetJitAccessConfig200JSONResponseBody0StateDisabled: - return true - case V1GetJitAccessConfig200JSONResponseBody0StateEnabled: - return true - default: - return false - } -} - -// Defines values for V1GetJitAccessConfig200JSONResponseBody1UnavailableReason. -const ( - V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonPlatformUnsupported V1GetJitAccessConfig200JSONResponseBody1UnavailableReason = "platform_unsupported" - V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonPostgresUpgradeRequired V1GetJitAccessConfig200JSONResponseBody1UnavailableReason = "postgres_upgrade_required" - V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonSslEnforcementRequired V1GetJitAccessConfig200JSONResponseBody1UnavailableReason = "ssl_enforcement_required" - V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonTemporarilyUnavailable V1GetJitAccessConfig200JSONResponseBody1UnavailableReason = "temporarily_unavailable" -) - -// Valid indicates whether the value is a known member of the V1GetJitAccessConfig200JSONResponseBody1UnavailableReason enum. -func (e V1GetJitAccessConfig200JSONResponseBody1UnavailableReason) Valid() bool { - switch e { - case V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonPlatformUnsupported: - return true - case V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonPostgresUpgradeRequired: - return true - case V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonSslEnforcementRequired: - return true - case V1GetJitAccessConfig200JSONResponseBody1UnavailableReasonTemporarilyUnavailable: - return true - default: - return false - } -} - -// Defines values for V1UpdateJitAccessConfig200JSONResponseBody0State. -const ( - Disabled V1UpdateJitAccessConfig200JSONResponseBody0State = "disabled" - Enabled V1UpdateJitAccessConfig200JSONResponseBody0State = "enabled" -) - -// Valid indicates whether the value is a known member of the V1UpdateJitAccessConfig200JSONResponseBody0State enum. -func (e V1UpdateJitAccessConfig200JSONResponseBody0State) Valid() bool { - switch e { - case Disabled: - return true - case Enabled: - return true - default: - return false - } -} - -// Defines values for V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason. -const ( - V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonPlatformUnsupported V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason = "platform_unsupported" - V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonPostgresUpgradeRequired V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason = "postgres_upgrade_required" - V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonSslEnforcementRequired V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason = "ssl_enforcement_required" - V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonTemporarilyUnavailable V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason = "temporarily_unavailable" -) - -// Valid indicates whether the value is a known member of the V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason enum. -func (e V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason) Valid() bool { - switch e { - case V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonPlatformUnsupported: - return true - case V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonPostgresUpgradeRequired: - return true - case V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonSslEnforcementRequired: - return true - case V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReasonTemporarilyUnavailable: - return true - default: - return false - } -} - -// Defines values for V1ListAllSnippetsParamsSortBy. -const ( - InsertedAt V1ListAllSnippetsParamsSortBy = "inserted_at" - Name V1ListAllSnippetsParamsSortBy = "name" -) - -// Valid indicates whether the value is a known member of the V1ListAllSnippetsParamsSortBy enum. -func (e V1ListAllSnippetsParamsSortBy) Valid() bool { - switch e { - case InsertedAt: - return true - case Name: - return true - default: - return false - } -} - -// Defines values for V1ListAllSnippetsParamsSortOrder. -const ( - Asc V1ListAllSnippetsParamsSortOrder = "asc" - Desc V1ListAllSnippetsParamsSortOrder = "desc" -) - -// Valid indicates whether the value is a known member of the V1ListAllSnippetsParamsSortOrder enum. -func (e V1ListAllSnippetsParamsSortOrder) Valid() bool { - switch e { - case Asc: - return true - case Desc: - return true - default: - return false - } -} - -// AcceptInviteExternalUserJitAccessBody defines model for AcceptInviteExternalUserJitAccessBody. -type AcceptInviteExternalUserJitAccessBody struct { - Email openapi_types.Email `json:"email"` - Token string `json:"token"` -} - -// ActionRunResponseOutput defines model for ActionRunResponse_Output. -type ActionRunResponseOutput struct { - BranchId string `json:"branch_id"` - CheckRunId nullable.Nullable[float32] `json:"check_run_id"` - CreatedAt string `json:"created_at"` - GitConfig nullable.Nullable[interface{}] `json:"git_config,omitempty"` - Id string `json:"id"` - RunSteps []struct { - CreatedAt string `json:"created_at"` - Name ActionRunResponseOutputRunStepsName `json:"name"` - Status ActionRunResponseOutputRunStepsStatus `json:"status"` - UpdatedAt string `json:"updated_at"` - } `json:"run_steps"` - UpdatedAt string `json:"updated_at"` - Workdir nullable.Nullable[string] `json:"workdir"` -} - -// ActionRunResponseOutputRunStepsName defines model for ActionRunResponseOutput.RunSteps.Name. -type ActionRunResponseOutputRunStepsName string - -// ActionRunResponseOutputRunStepsStatus defines model for ActionRunResponseOutput.RunSteps.Status. -type ActionRunResponseOutputRunStepsStatus string - -// ActivateVanitySubdomainResponseOutput defines model for ActivateVanitySubdomainResponse_Output. -type ActivateVanitySubdomainResponseOutput struct { - CustomDomain string `json:"custom_domain"` -} - -// AnalyticsResponseOutput defines model for AnalyticsResponse_Output. -type AnalyticsResponseOutput struct { - Error *AnalyticsResponseOutput_Error `json:"error,omitempty"` - Result *[]interface{} `json:"result,omitempty"` -} - -// AnalyticsResponseOutputError0 defines model for . -type AnalyticsResponseOutputError0 = string - -// AnalyticsResponseOutputError1 defines model for . -type AnalyticsResponseOutputError1 struct { - Code float32 `json:"code"` - Errors []struct { - Domain string `json:"domain"` - Location string `json:"location"` - LocationType string `json:"locationType"` - Message string `json:"message"` - Reason string `json:"reason"` - } `json:"errors"` - Message string `json:"message"` - Status string `json:"status"` -} - -// AnalyticsResponseOutput_Error defines model for AnalyticsResponseOutput.Error. -type AnalyticsResponseOutput_Error struct { - union json.RawMessage -} - -// ApiKeyResponseOutput defines model for ApiKeyResponse_Output. -type ApiKeyResponseOutput struct { - ApiKey nullable.Nullable[string] `json:"api_key,omitempty"` - Description nullable.Nullable[string] `json:"description,omitempty"` - Hash nullable.Nullable[string] `json:"hash,omitempty"` - Id nullable.Nullable[string] `json:"id,omitempty"` - InsertedAt nullable.Nullable[time.Time] `json:"inserted_at,omitempty"` - Name string `json:"name"` - Prefix nullable.Nullable[string] `json:"prefix,omitempty"` - SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` - Type nullable.Nullable[ApiKeyResponseOutputType] `json:"type,omitempty"` - UpdatedAt nullable.Nullable[time.Time] `json:"updated_at,omitempty"` -} - -// ApiKeyResponseOutputType defines model for ApiKeyResponseOutput.Type. -type ApiKeyResponseOutputType string - -// ApplyProjectAddonBody defines model for ApplyProjectAddonBody. -type ApplyProjectAddonBody struct { - AddonType ApplyProjectAddonBodyAddonType `json:"addon_type"` - AddonVariant ApplyProjectAddonBody_AddonVariant `json:"addon_variant"` -} - -// ApplyProjectAddonBodyAddonType defines model for ApplyProjectAddonBody.AddonType. -type ApplyProjectAddonBodyAddonType string - -// ApplyProjectAddonBodyAddonVariant0 defines model for ApplyProjectAddonBody.AddonVariant.0. -type ApplyProjectAddonBodyAddonVariant0 string - -// ApplyProjectAddonBodyAddonVariant1 defines model for ApplyProjectAddonBody.AddonVariant.1. -type ApplyProjectAddonBodyAddonVariant1 string - -// ApplyProjectAddonBodyAddonVariant2 defines model for ApplyProjectAddonBody.AddonVariant.2. -type ApplyProjectAddonBodyAddonVariant2 string - -// ApplyProjectAddonBodyAddonVariant3 defines model for ApplyProjectAddonBody.AddonVariant.3. -type ApplyProjectAddonBodyAddonVariant3 string - -// ApplyProjectAddonBody_AddonVariant defines model for ApplyProjectAddonBody.AddonVariant. -type ApplyProjectAddonBody_AddonVariant struct { - union json.RawMessage -} - -// AuthConfigResponseOutput defines model for AuthConfigResponse_Output. -type AuthConfigResponseOutput struct { - ApiMaxRequestDuration nullable.Nullable[int] `json:"api_max_request_duration"` - CustomOauthEnabled bool `json:"custom_oauth_enabled"` - CustomOauthMaxProviders int `json:"custom_oauth_max_providers"` - DbMaxPoolSize nullable.Nullable[int] `json:"db_max_pool_size"` - DbMaxPoolSizeUnit nullable.Nullable[AuthConfigResponseOutputDbMaxPoolSizeUnit] `json:"db_max_pool_size_unit"` - DisableSignup nullable.Nullable[bool] `json:"disable_signup"` - ExternalAnonymousUsersEnabled nullable.Nullable[bool] `json:"external_anonymous_users_enabled"` - ExternalAppleAdditionalClientIds nullable.Nullable[string] `json:"external_apple_additional_client_ids"` - ExternalAppleClientId nullable.Nullable[string] `json:"external_apple_client_id"` - ExternalAppleEmailOptional nullable.Nullable[bool] `json:"external_apple_email_optional"` - ExternalAppleEnabled nullable.Nullable[bool] `json:"external_apple_enabled"` - ExternalAppleSecret nullable.Nullable[string] `json:"external_apple_secret"` - ExternalAzureClientId nullable.Nullable[string] `json:"external_azure_client_id"` - ExternalAzureEmailOptional nullable.Nullable[bool] `json:"external_azure_email_optional"` - ExternalAzureEnabled nullable.Nullable[bool] `json:"external_azure_enabled"` - ExternalAzureSecret nullable.Nullable[string] `json:"external_azure_secret"` - ExternalAzureUrl nullable.Nullable[string] `json:"external_azure_url"` - ExternalBitbucketClientId nullable.Nullable[string] `json:"external_bitbucket_client_id"` - ExternalBitbucketEmailOptional nullable.Nullable[bool] `json:"external_bitbucket_email_optional"` - ExternalBitbucketEnabled nullable.Nullable[bool] `json:"external_bitbucket_enabled"` - ExternalBitbucketSecret nullable.Nullable[string] `json:"external_bitbucket_secret"` - ExternalDiscordClientId nullable.Nullable[string] `json:"external_discord_client_id"` - ExternalDiscordEmailOptional nullable.Nullable[bool] `json:"external_discord_email_optional"` - ExternalDiscordEnabled nullable.Nullable[bool] `json:"external_discord_enabled"` - ExternalDiscordSecret nullable.Nullable[string] `json:"external_discord_secret"` - ExternalEmailEnabled nullable.Nullable[bool] `json:"external_email_enabled"` - ExternalFacebookClientId nullable.Nullable[string] `json:"external_facebook_client_id"` - ExternalFacebookEmailOptional nullable.Nullable[bool] `json:"external_facebook_email_optional"` - ExternalFacebookEnabled nullable.Nullable[bool] `json:"external_facebook_enabled"` - ExternalFacebookSecret nullable.Nullable[string] `json:"external_facebook_secret"` - ExternalFigmaClientId nullable.Nullable[string] `json:"external_figma_client_id"` - ExternalFigmaEmailOptional nullable.Nullable[bool] `json:"external_figma_email_optional"` - ExternalFigmaEnabled nullable.Nullable[bool] `json:"external_figma_enabled"` - ExternalFigmaSecret nullable.Nullable[string] `json:"external_figma_secret"` - ExternalGithubClientId nullable.Nullable[string] `json:"external_github_client_id"` - ExternalGithubEmailOptional nullable.Nullable[bool] `json:"external_github_email_optional"` - ExternalGithubEnabled nullable.Nullable[bool] `json:"external_github_enabled"` - ExternalGithubSecret nullable.Nullable[string] `json:"external_github_secret"` - ExternalGitlabClientId nullable.Nullable[string] `json:"external_gitlab_client_id"` - ExternalGitlabEmailOptional nullable.Nullable[bool] `json:"external_gitlab_email_optional"` - ExternalGitlabEnabled nullable.Nullable[bool] `json:"external_gitlab_enabled"` - ExternalGitlabSecret nullable.Nullable[string] `json:"external_gitlab_secret"` - ExternalGitlabUrl nullable.Nullable[string] `json:"external_gitlab_url"` - ExternalGoogleAdditionalClientIds nullable.Nullable[string] `json:"external_google_additional_client_ids"` - ExternalGoogleClientId nullable.Nullable[string] `json:"external_google_client_id"` - ExternalGoogleEmailOptional nullable.Nullable[bool] `json:"external_google_email_optional"` - ExternalGoogleEnabled nullable.Nullable[bool] `json:"external_google_enabled"` - ExternalGoogleSecret nullable.Nullable[string] `json:"external_google_secret"` - ExternalGoogleSkipNonceCheck nullable.Nullable[bool] `json:"external_google_skip_nonce_check"` - ExternalKakaoClientId nullable.Nullable[string] `json:"external_kakao_client_id"` - ExternalKakaoEmailOptional nullable.Nullable[bool] `json:"external_kakao_email_optional"` - ExternalKakaoEnabled nullable.Nullable[bool] `json:"external_kakao_enabled"` - ExternalKakaoSecret nullable.Nullable[string] `json:"external_kakao_secret"` - ExternalKeycloakClientId nullable.Nullable[string] `json:"external_keycloak_client_id"` - ExternalKeycloakEmailOptional nullable.Nullable[bool] `json:"external_keycloak_email_optional"` - ExternalKeycloakEnabled nullable.Nullable[bool] `json:"external_keycloak_enabled"` - ExternalKeycloakSecret nullable.Nullable[string] `json:"external_keycloak_secret"` - ExternalKeycloakUrl nullable.Nullable[string] `json:"external_keycloak_url"` - ExternalLinkedinOidcClientId nullable.Nullable[string] `json:"external_linkedin_oidc_client_id"` - ExternalLinkedinOidcEmailOptional nullable.Nullable[bool] `json:"external_linkedin_oidc_email_optional"` - ExternalLinkedinOidcEnabled nullable.Nullable[bool] `json:"external_linkedin_oidc_enabled"` - ExternalLinkedinOidcSecret nullable.Nullable[string] `json:"external_linkedin_oidc_secret"` - ExternalNotionClientId nullable.Nullable[string] `json:"external_notion_client_id"` - ExternalNotionEmailOptional nullable.Nullable[bool] `json:"external_notion_email_optional"` - ExternalNotionEnabled nullable.Nullable[bool] `json:"external_notion_enabled"` - ExternalNotionSecret nullable.Nullable[string] `json:"external_notion_secret"` - ExternalPhoneEnabled nullable.Nullable[bool] `json:"external_phone_enabled"` - ExternalSlackClientId nullable.Nullable[string] `json:"external_slack_client_id"` - ExternalSlackEmailOptional nullable.Nullable[bool] `json:"external_slack_email_optional"` - ExternalSlackEnabled nullable.Nullable[bool] `json:"external_slack_enabled"` - ExternalSlackOidcClientId nullable.Nullable[string] `json:"external_slack_oidc_client_id"` - ExternalSlackOidcEmailOptional nullable.Nullable[bool] `json:"external_slack_oidc_email_optional"` - ExternalSlackOidcEnabled nullable.Nullable[bool] `json:"external_slack_oidc_enabled"` - ExternalSlackOidcSecret nullable.Nullable[string] `json:"external_slack_oidc_secret"` - ExternalSlackSecret nullable.Nullable[string] `json:"external_slack_secret"` - ExternalSpotifyClientId nullable.Nullable[string] `json:"external_spotify_client_id"` - ExternalSpotifyEmailOptional nullable.Nullable[bool] `json:"external_spotify_email_optional"` - ExternalSpotifyEnabled nullable.Nullable[bool] `json:"external_spotify_enabled"` - ExternalSpotifySecret nullable.Nullable[string] `json:"external_spotify_secret"` - ExternalTwitchClientId nullable.Nullable[string] `json:"external_twitch_client_id"` - ExternalTwitchEmailOptional nullable.Nullable[bool] `json:"external_twitch_email_optional"` - ExternalTwitchEnabled nullable.Nullable[bool] `json:"external_twitch_enabled"` - ExternalTwitchSecret nullable.Nullable[string] `json:"external_twitch_secret"` - ExternalTwitterClientId nullable.Nullable[string] `json:"external_twitter_client_id"` - ExternalTwitterEmailOptional nullable.Nullable[bool] `json:"external_twitter_email_optional"` - ExternalTwitterEnabled nullable.Nullable[bool] `json:"external_twitter_enabled"` - ExternalTwitterSecret nullable.Nullable[string] `json:"external_twitter_secret"` - ExternalWeb3EthereumEnabled nullable.Nullable[bool] `json:"external_web3_ethereum_enabled"` - ExternalWeb3SolanaEnabled nullable.Nullable[bool] `json:"external_web3_solana_enabled"` - ExternalWorkosClientId nullable.Nullable[string] `json:"external_workos_client_id"` - ExternalWorkosEnabled nullable.Nullable[bool] `json:"external_workos_enabled"` - ExternalWorkosSecret nullable.Nullable[string] `json:"external_workos_secret"` - ExternalWorkosUrl nullable.Nullable[string] `json:"external_workos_url"` - ExternalXClientId nullable.Nullable[string] `json:"external_x_client_id"` - ExternalXEmailOptional nullable.Nullable[bool] `json:"external_x_email_optional"` - ExternalXEnabled nullable.Nullable[bool] `json:"external_x_enabled"` - ExternalXSecret nullable.Nullable[string] `json:"external_x_secret"` - ExternalZoomClientId nullable.Nullable[string] `json:"external_zoom_client_id"` - ExternalZoomEmailOptional nullable.Nullable[bool] `json:"external_zoom_email_optional"` - ExternalZoomEnabled nullable.Nullable[bool] `json:"external_zoom_enabled"` - ExternalZoomSecret nullable.Nullable[string] `json:"external_zoom_secret"` - HookAfterUserCreatedEnabled nullable.Nullable[bool] `json:"hook_after_user_created_enabled"` - HookAfterUserCreatedSecrets nullable.Nullable[string] `json:"hook_after_user_created_secrets"` - HookAfterUserCreatedUri nullable.Nullable[string] `json:"hook_after_user_created_uri"` - HookBeforeUserCreatedEnabled nullable.Nullable[bool] `json:"hook_before_user_created_enabled"` - HookBeforeUserCreatedSecrets nullable.Nullable[string] `json:"hook_before_user_created_secrets"` - HookBeforeUserCreatedUri nullable.Nullable[string] `json:"hook_before_user_created_uri"` - HookCustomAccessTokenEnabled nullable.Nullable[bool] `json:"hook_custom_access_token_enabled"` - HookCustomAccessTokenSecrets nullable.Nullable[string] `json:"hook_custom_access_token_secrets"` - HookCustomAccessTokenUri nullable.Nullable[string] `json:"hook_custom_access_token_uri"` - HookMfaVerificationAttemptEnabled nullable.Nullable[bool] `json:"hook_mfa_verification_attempt_enabled"` - HookMfaVerificationAttemptSecrets nullable.Nullable[string] `json:"hook_mfa_verification_attempt_secrets"` - HookMfaVerificationAttemptUri nullable.Nullable[string] `json:"hook_mfa_verification_attempt_uri"` - HookPasswordVerificationAttemptEnabled nullable.Nullable[bool] `json:"hook_password_verification_attempt_enabled"` - HookPasswordVerificationAttemptSecrets nullable.Nullable[string] `json:"hook_password_verification_attempt_secrets"` - HookPasswordVerificationAttemptUri nullable.Nullable[string] `json:"hook_password_verification_attempt_uri"` - HookSendEmailEnabled nullable.Nullable[bool] `json:"hook_send_email_enabled"` - HookSendEmailSecrets nullable.Nullable[string] `json:"hook_send_email_secrets"` - HookSendEmailUri nullable.Nullable[string] `json:"hook_send_email_uri"` - HookSendSmsEnabled nullable.Nullable[bool] `json:"hook_send_sms_enabled"` - HookSendSmsSecrets nullable.Nullable[string] `json:"hook_send_sms_secrets"` - HookSendSmsUri nullable.Nullable[string] `json:"hook_send_sms_uri"` - JwtExp nullable.Nullable[int] `json:"jwt_exp"` - MailerAllowUnverifiedEmailSignIns nullable.Nullable[bool] `json:"mailer_allow_unverified_email_sign_ins"` - MailerAutoconfirm nullable.Nullable[bool] `json:"mailer_autoconfirm"` - MailerNotificationsEmailChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_email_changed_enabled"` - MailerNotificationsIdentityLinkedEnabled nullable.Nullable[bool] `json:"mailer_notifications_identity_linked_enabled"` - MailerNotificationsIdentityUnlinkedEnabled nullable.Nullable[bool] `json:"mailer_notifications_identity_unlinked_enabled"` - MailerNotificationsMfaFactorEnrolledEnabled nullable.Nullable[bool] `json:"mailer_notifications_mfa_factor_enrolled_enabled"` - MailerNotificationsMfaFactorUnenrolledEnabled nullable.Nullable[bool] `json:"mailer_notifications_mfa_factor_unenrolled_enabled"` - MailerNotificationsPasswordChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_password_changed_enabled"` - MailerNotificationsPhoneChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_phone_changed_enabled"` - MailerOtpExp int `json:"mailer_otp_exp"` - MailerOtpLength nullable.Nullable[int] `json:"mailer_otp_length"` - MailerSecureEmailChangeEnabled nullable.Nullable[bool] `json:"mailer_secure_email_change_enabled"` - MailerSubjectsConfirmation nullable.Nullable[string] `json:"mailer_subjects_confirmation"` - MailerSubjectsEmailChange nullable.Nullable[string] `json:"mailer_subjects_email_change"` - MailerSubjectsEmailChangedNotification nullable.Nullable[string] `json:"mailer_subjects_email_changed_notification"` - MailerSubjectsIdentityLinkedNotification nullable.Nullable[string] `json:"mailer_subjects_identity_linked_notification"` - MailerSubjectsIdentityUnlinkedNotification nullable.Nullable[string] `json:"mailer_subjects_identity_unlinked_notification"` - MailerSubjectsInvite nullable.Nullable[string] `json:"mailer_subjects_invite"` - MailerSubjectsMagicLink nullable.Nullable[string] `json:"mailer_subjects_magic_link"` - MailerSubjectsMfaFactorEnrolledNotification nullable.Nullable[string] `json:"mailer_subjects_mfa_factor_enrolled_notification"` - MailerSubjectsMfaFactorUnenrolledNotification nullable.Nullable[string] `json:"mailer_subjects_mfa_factor_unenrolled_notification"` - MailerSubjectsPasswordChangedNotification nullable.Nullable[string] `json:"mailer_subjects_password_changed_notification"` - MailerSubjectsPhoneChangedNotification nullable.Nullable[string] `json:"mailer_subjects_phone_changed_notification"` - MailerSubjectsReauthentication nullable.Nullable[string] `json:"mailer_subjects_reauthentication"` - MailerSubjectsRecovery nullable.Nullable[string] `json:"mailer_subjects_recovery"` - MailerTemplatesConfirmationContent nullable.Nullable[string] `json:"mailer_templates_confirmation_content"` - MailerTemplatesEmailChangeContent nullable.Nullable[string] `json:"mailer_templates_email_change_content"` - MailerTemplatesEmailChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_email_changed_notification_content"` - MailerTemplatesIdentityLinkedNotificationContent nullable.Nullable[string] `json:"mailer_templates_identity_linked_notification_content"` - MailerTemplatesIdentityUnlinkedNotificationContent nullable.Nullable[string] `json:"mailer_templates_identity_unlinked_notification_content"` - MailerTemplatesInviteContent nullable.Nullable[string] `json:"mailer_templates_invite_content"` - MailerTemplatesMagicLinkContent nullable.Nullable[string] `json:"mailer_templates_magic_link_content"` - MailerTemplatesMfaFactorEnrolledNotificationContent nullable.Nullable[string] `json:"mailer_templates_mfa_factor_enrolled_notification_content"` - MailerTemplatesMfaFactorUnenrolledNotificationContent nullable.Nullable[string] `json:"mailer_templates_mfa_factor_unenrolled_notification_content"` - MailerTemplatesPasswordChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_password_changed_notification_content"` - MailerTemplatesPhoneChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_phone_changed_notification_content"` - MailerTemplatesReauthenticationContent nullable.Nullable[string] `json:"mailer_templates_reauthentication_content"` - MailerTemplatesRecoveryContent nullable.Nullable[string] `json:"mailer_templates_recovery_content"` - MfaMaxEnrolledFactors nullable.Nullable[int] `json:"mfa_max_enrolled_factors"` - MfaPhoneEnrollEnabled nullable.Nullable[bool] `json:"mfa_phone_enroll_enabled"` - MfaPhoneMaxFrequency nullable.Nullable[int] `json:"mfa_phone_max_frequency"` - MfaPhoneOtpLength int `json:"mfa_phone_otp_length"` - MfaPhoneTemplate nullable.Nullable[string] `json:"mfa_phone_template"` - MfaPhoneVerifyEnabled nullable.Nullable[bool] `json:"mfa_phone_verify_enabled"` - MfaTotpEnrollEnabled nullable.Nullable[bool] `json:"mfa_totp_enroll_enabled"` - MfaTotpVerifyEnabled nullable.Nullable[bool] `json:"mfa_totp_verify_enabled"` - MfaWebAuthnEnrollEnabled nullable.Nullable[bool] `json:"mfa_web_authn_enroll_enabled"` - MfaWebAuthnVerifyEnabled nullable.Nullable[bool] `json:"mfa_web_authn_verify_enabled"` - NimbusOauthClientId nullable.Nullable[string] `json:"nimbus_oauth_client_id"` - NimbusOauthClientSecret nullable.Nullable[string] `json:"nimbus_oauth_client_secret"` - NimbusOauthEmailOptional nullable.Nullable[bool] `json:"nimbus_oauth_email_optional"` - OauthServerAllowDynamicRegistration bool `json:"oauth_server_allow_dynamic_registration"` - OauthServerAuthorizationPath nullable.Nullable[string] `json:"oauth_server_authorization_path"` - OauthServerEnabled bool `json:"oauth_server_enabled"` - PasskeyEnabled bool `json:"passkey_enabled"` - PasswordHibpEnabled nullable.Nullable[bool] `json:"password_hibp_enabled"` - PasswordMinLength nullable.Nullable[int] `json:"password_min_length"` - PasswordRequiredCharacters nullable.Nullable[AuthConfigResponseOutputPasswordRequiredCharacters] `json:"password_required_characters"` - RateLimitAnonymousUsers nullable.Nullable[int] `json:"rate_limit_anonymous_users"` - RateLimitEmailSent nullable.Nullable[int] `json:"rate_limit_email_sent"` - RateLimitOtp nullable.Nullable[int] `json:"rate_limit_otp"` - RateLimitSmsSent nullable.Nullable[int] `json:"rate_limit_sms_sent"` - RateLimitTokenRefresh nullable.Nullable[int] `json:"rate_limit_token_refresh"` - RateLimitVerify nullable.Nullable[int] `json:"rate_limit_verify"` - RateLimitWeb3 nullable.Nullable[int] `json:"rate_limit_web3"` - RefreshTokenRotationEnabled nullable.Nullable[bool] `json:"refresh_token_rotation_enabled"` - SamlAllowEncryptedAssertions nullable.Nullable[bool] `json:"saml_allow_encrypted_assertions"` - SamlEnabled nullable.Nullable[bool] `json:"saml_enabled"` - SamlExternalUrl nullable.Nullable[string] `json:"saml_external_url"` - SecurityCaptchaEnabled nullable.Nullable[bool] `json:"security_captcha_enabled"` - SecurityCaptchaProvider nullable.Nullable[AuthConfigResponseOutputSecurityCaptchaProvider] `json:"security_captcha_provider"` - SecurityCaptchaSecret nullable.Nullable[string] `json:"security_captcha_secret"` - SecurityManualLinkingEnabled nullable.Nullable[bool] `json:"security_manual_linking_enabled"` - SecurityRefreshTokenReuseInterval nullable.Nullable[int] `json:"security_refresh_token_reuse_interval"` - SecuritySbForwardedForEnabled nullable.Nullable[bool] `json:"security_sb_forwarded_for_enabled"` - SecurityUpdatePasswordRequireCurrentPassword nullable.Nullable[bool] `json:"security_update_password_require_current_password"` - SecurityUpdatePasswordRequireReauthentication nullable.Nullable[bool] `json:"security_update_password_require_reauthentication"` - SessionsInactivityTimeout nullable.Nullable[float32] `json:"sessions_inactivity_timeout"` - SessionsSinglePerUser nullable.Nullable[bool] `json:"sessions_single_per_user"` - SessionsTags nullable.Nullable[string] `json:"sessions_tags"` - SessionsTimebox nullable.Nullable[float32] `json:"sessions_timebox"` - SiteUrl nullable.Nullable[string] `json:"site_url"` - SmsAutoconfirm nullable.Nullable[bool] `json:"sms_autoconfirm"` - SmsMaxFrequency nullable.Nullable[int] `json:"sms_max_frequency"` - SmsMessagebirdAccessKey nullable.Nullable[string] `json:"sms_messagebird_access_key"` - SmsMessagebirdOriginator nullable.Nullable[string] `json:"sms_messagebird_originator"` - SmsOtpExp nullable.Nullable[int] `json:"sms_otp_exp"` - SmsOtpLength int `json:"sms_otp_length"` - SmsProvider nullable.Nullable[AuthConfigResponseOutputSmsProvider] `json:"sms_provider"` - SmsTemplate nullable.Nullable[string] `json:"sms_template"` - SmsTestOtp nullable.Nullable[string] `json:"sms_test_otp"` - SmsTestOtpValidUntil nullable.Nullable[time.Time] `json:"sms_test_otp_valid_until"` - SmsTextlocalApiKey nullable.Nullable[string] `json:"sms_textlocal_api_key"` - SmsTextlocalSender nullable.Nullable[string] `json:"sms_textlocal_sender"` - SmsTwilioAccountSid nullable.Nullable[string] `json:"sms_twilio_account_sid"` - SmsTwilioAuthToken nullable.Nullable[string] `json:"sms_twilio_auth_token"` - SmsTwilioContentSid nullable.Nullable[string] `json:"sms_twilio_content_sid"` - SmsTwilioMessageServiceSid nullable.Nullable[string] `json:"sms_twilio_message_service_sid"` - SmsTwilioVerifyAccountSid nullable.Nullable[string] `json:"sms_twilio_verify_account_sid"` - SmsTwilioVerifyAuthToken nullable.Nullable[string] `json:"sms_twilio_verify_auth_token"` - SmsTwilioVerifyMessageServiceSid nullable.Nullable[string] `json:"sms_twilio_verify_message_service_sid"` - SmsVonageApiKey nullable.Nullable[string] `json:"sms_vonage_api_key"` - SmsVonageApiSecret nullable.Nullable[string] `json:"sms_vonage_api_secret"` - SmsVonageFrom nullable.Nullable[string] `json:"sms_vonage_from"` - SmtpAdminEmail nullable.Nullable[openapi_types.Email] `json:"smtp_admin_email"` - SmtpHost nullable.Nullable[string] `json:"smtp_host"` - SmtpMaxFrequency nullable.Nullable[int] `json:"smtp_max_frequency"` - SmtpPass nullable.Nullable[string] `json:"smtp_pass"` - SmtpPort nullable.Nullable[string] `json:"smtp_port"` - SmtpSenderName nullable.Nullable[string] `json:"smtp_sender_name"` - SmtpUser nullable.Nullable[string] `json:"smtp_user"` - UriAllowList nullable.Nullable[string] `json:"uri_allow_list"` - WebauthnRpDisplayName nullable.Nullable[string] `json:"webauthn_rp_display_name"` - WebauthnRpId nullable.Nullable[string] `json:"webauthn_rp_id"` - WebauthnRpOrigins nullable.Nullable[string] `json:"webauthn_rp_origins"` -} - -// AuthConfigResponseOutputDbMaxPoolSizeUnit defines model for AuthConfigResponseOutput.DbMaxPoolSizeUnit. -type AuthConfigResponseOutputDbMaxPoolSizeUnit string - -// AuthConfigResponseOutputPasswordRequiredCharacters defines model for AuthConfigResponseOutput.PasswordRequiredCharacters. -type AuthConfigResponseOutputPasswordRequiredCharacters string - -// AuthConfigResponseOutputSecurityCaptchaProvider defines model for AuthConfigResponseOutput.SecurityCaptchaProvider. -type AuthConfigResponseOutputSecurityCaptchaProvider string - -// AuthConfigResponseOutputSmsProvider defines model for AuthConfigResponseOutput.SmsProvider. -type AuthConfigResponseOutputSmsProvider string - -// AuthorizeJitAccessBody defines model for AuthorizeJitAccessBody. -type AuthorizeJitAccessBody struct { - Rhost AuthorizeJitAccessBody_Rhost `json:"rhost"` - Role string `json:"role"` -} - -// AuthorizeJitAccessBodyRhost0 defines model for . -type AuthorizeJitAccessBodyRhost0 = string - -// AuthorizeJitAccessBodyRhost1 defines model for . -type AuthorizeJitAccessBodyRhost1 = string - -// AuthorizeJitAccessBody_Rhost defines model for AuthorizeJitAccessBody.Rhost. -type AuthorizeJitAccessBody_Rhost struct { - union json.RawMessage -} - -// BranchActionBody defines model for BranchActionBody. -type BranchActionBody struct { - MigrationVersion *string `json:"migration_version,omitempty"` -} - -// BranchDeleteResponseOutput defines model for BranchDeleteResponse_Output. -type BranchDeleteResponseOutput struct { - Message BranchDeleteResponseOutputMessage `json:"message"` -} - -// BranchDeleteResponseOutputMessage defines model for BranchDeleteResponseOutput.Message. -type BranchDeleteResponseOutputMessage string - -// BranchDetailResponseOutput defines model for BranchDetailResponse_Output. -type BranchDetailResponseOutput struct { - DbHost string `json:"db_host"` - DbPass *string `json:"db_pass,omitempty"` - DbPort int `json:"db_port"` - DbUser *string `json:"db_user,omitempty"` - JwtSecret *string `json:"jwt_secret,omitempty"` - PostgresEngine string `json:"postgres_engine"` - PostgresVersion string `json:"postgres_version"` - Ref string `json:"ref"` - ReleaseChannel string `json:"release_channel"` - Status BranchDetailResponseOutputStatus `json:"status"` -} - -// BranchDetailResponseOutputStatus defines model for BranchDetailResponseOutput.Status. -type BranchDetailResponseOutputStatus string - -// BranchResponseOutput defines model for BranchResponse_Output. -type BranchResponseOutput struct { - CreatedAt time.Time `json:"created_at"` - DeletionScheduledAt *time.Time `json:"deletion_scheduled_at,omitempty"` - GitBranch *string `json:"git_branch,omitempty"` - Id openapi_types.UUID `json:"id"` - IsDefault bool `json:"is_default"` - - // LatestCheckRunId This field is deprecated and will not be populated. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - LatestCheckRunId *float32 `json:"latest_check_run_id,omitempty"` - Name string `json:"name"` - NotifyUrl *string `json:"notify_url,omitempty"` - ParentProjectRef string `json:"parent_project_ref"` - Persistent bool `json:"persistent"` - PrNumber *int32 `json:"pr_number,omitempty"` - PreviewProjectStatus *BranchResponseOutputPreviewProjectStatus `json:"preview_project_status,omitempty"` - ProjectRef string `json:"project_ref"` - ReviewRequestedAt *time.Time `json:"review_requested_at,omitempty"` - - // Status This field is deprecated. List action runs to get branch status instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Status BranchResponseOutputStatus `json:"status"` - UpdatedAt time.Time `json:"updated_at"` - WithData bool `json:"with_data"` -} - -// BranchResponseOutputPreviewProjectStatus defines model for BranchResponseOutput.PreviewProjectStatus. -type BranchResponseOutputPreviewProjectStatus string - -// BranchResponseOutputStatus This field is deprecated. List action runs to get branch status instead. -type BranchResponseOutputStatus string - -// BranchRestoreResponseOutput defines model for BranchRestoreResponse_Output. -type BranchRestoreResponseOutput struct { - Message BranchRestoreResponseOutputMessage `json:"message"` -} - -// BranchRestoreResponseOutputMessage defines model for BranchRestoreResponseOutput.Message. -type BranchRestoreResponseOutputMessage string - -// BranchUpdateResponseOutput defines model for BranchUpdateResponse_Output. -type BranchUpdateResponseOutput struct { - Message BranchUpdateResponseOutputMessage `json:"message"` - WorkflowRunId string `json:"workflow_run_id"` -} - -// BranchUpdateResponseOutputMessage defines model for BranchUpdateResponseOutput.Message. -type BranchUpdateResponseOutputMessage string - -// BulkUpdateFunctionBody defines model for BulkUpdateFunctionBody. -type BulkUpdateFunctionBody = []struct { - CreatedAt *int64 `json:"created_at,omitempty"` - EntrypointPath *string `json:"entrypoint_path,omitempty"` - EzbrSha256 *string `json:"ezbr_sha256,omitempty"` - Id string `json:"id"` - ImportMap *bool `json:"import_map,omitempty"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` - Status BulkUpdateFunctionBodyStatus `json:"status"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - Version int `json:"version"` -} - -// BulkUpdateFunctionBodyStatus defines model for BulkUpdateFunctionBody.Status. -type BulkUpdateFunctionBodyStatus string - -// BulkUpdateFunctionResponseOutput defines model for BulkUpdateFunctionResponse_Output. -type BulkUpdateFunctionResponseOutput struct { - Functions []struct { - CreatedAt int64 `json:"created_at"` - EntrypointPath *string `json:"entrypoint_path,omitempty"` - EzbrSha256 *string `json:"ezbr_sha256,omitempty"` - Id string `json:"id"` - ImportMap *bool `json:"import_map,omitempty"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` - Status BulkUpdateFunctionResponseOutputFunctionsStatus `json:"status"` - UpdatedAt int64 `json:"updated_at"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - Version int `json:"version"` - } `json:"functions"` -} - -// BulkUpdateFunctionResponseOutputFunctionsStatus defines model for BulkUpdateFunctionResponseOutput.Functions.Status. -type BulkUpdateFunctionResponseOutputFunctionsStatus string - -// CreateApiKeyBody defines model for CreateApiKeyBody. -type CreateApiKeyBody struct { - Description nullable.Nullable[string] `json:"description,omitempty"` - Name string `json:"name"` - SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` - Type CreateApiKeyBodyType `json:"type"` -} - -// CreateApiKeyBodyType defines model for CreateApiKeyBody.Type. -type CreateApiKeyBodyType string - -// CreateBranchBody defines model for CreateBranchBody. -type CreateBranchBody struct { - BranchName string `json:"branch_name"` - DesiredInstanceSize *CreateBranchBodyDesiredInstanceSize `json:"desired_instance_size,omitempty"` - GitBranch *string `json:"git_branch,omitempty"` - IsDefault *bool `json:"is_default,omitempty"` - - // NotifyUrl HTTP endpoint to receive branch status updates. - NotifyUrl *string `json:"notify_url,omitempty"` - Persistent *bool `json:"persistent,omitempty"` - - // PostgresEngine Postgres engine version. If not provided, the latest version will be used. - PostgresEngine *CreateBranchBodyPostgresEngine `json:"postgres_engine,omitempty"` - Region *string `json:"region,omitempty"` - - // ReleaseChannel Release channel. If not provided, GA will be used. - ReleaseChannel *CreateBranchBodyReleaseChannel `json:"release_channel,omitempty"` - Secrets *map[string]string `json:"secrets,omitempty"` - WithData *bool `json:"with_data,omitempty"` -} - -// CreateBranchBodyDesiredInstanceSize defines model for CreateBranchBody.DesiredInstanceSize. -type CreateBranchBodyDesiredInstanceSize string - -// CreateBranchBodyPostgresEngine Postgres engine version. If not provided, the latest version will be used. -type CreateBranchBodyPostgresEngine string - -// CreateBranchBodyReleaseChannel Release channel. If not provided, GA will be used. -type CreateBranchBodyReleaseChannel string - -// CreateOrganizationV1 defines model for CreateOrganizationV1. -type CreateOrganizationV1 struct { - Name string `json:"name"` -} - -// CreateProjectClaimTokenResponseOutput defines model for CreateProjectClaimTokenResponse_Output. -type CreateProjectClaimTokenResponseOutput struct { - CreatedAt string `json:"created_at"` - CreatedBy openapi_types.UUID `json:"created_by"` - ExpiresAt string `json:"expires_at"` - Token string `json:"token"` - TokenAlias string `json:"token_alias"` -} - -// CreateProviderBody defines model for CreateProviderBody. -type CreateProviderBody struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - Domains *[]string `json:"domains,omitempty"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *CreateProviderBodyNameIdFormat `json:"name_id_format,omitempty"` - - // Type What type of provider will be created - Type CreateProviderBodyType `json:"type"` -} - -// CreateProviderBodyNameIdFormat defines model for CreateProviderBody.NameIdFormat. -type CreateProviderBodyNameIdFormat string - -// CreateProviderBodyType What type of provider will be created -type CreateProviderBodyType string - -// CreateProviderResponseOutput defines model for CreateProviderResponse_Output. -type CreateProviderResponseOutput struct { - CreatedAt *string `json:"created_at,omitempty"` - Domains *[]struct { - CreatedAt *string `json:"created_at,omitempty"` - Domain *string `json:"domain,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"domains,omitempty"` - Id string `json:"id"` - Saml *struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - EntityId string `json:"entity_id"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *string `json:"name_id_format,omitempty"` - } `json:"saml,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` -} - -// CreateRoleBody defines model for CreateRoleBody. -type CreateRoleBody struct { - ReadOnly bool `json:"read_only"` -} - -// CreateRoleResponseOutput defines model for CreateRoleResponse_Output. -type CreateRoleResponseOutput struct { - Password string `json:"password"` - Role string `json:"role"` - TtlSeconds int64 `json:"ttl_seconds"` -} - -// CreateSecretBody defines model for CreateSecretBody. -type CreateSecretBody = []struct { - // Name Secret name must not start with the SUPABASE_ prefix. - Name string `json:"name"` - Value string `json:"value"` -} - -// CreateSigningKeyBody defines model for CreateSigningKeyBody. -type CreateSigningKeyBody struct { - Algorithm CreateSigningKeyBodyAlgorithm `json:"algorithm"` - PrivateJwk *CreateSigningKeyBody_PrivateJwk `json:"private_jwk,omitempty"` - Status *CreateSigningKeyBodyStatus `json:"status,omitempty"` -} - -// CreateSigningKeyBodyAlgorithm defines model for CreateSigningKeyBody.Algorithm. -type CreateSigningKeyBodyAlgorithm string - -// CreateSigningKeyBodyPrivateJwk0 defines model for . -type CreateSigningKeyBodyPrivateJwk0 struct { - Alg *CreateSigningKeyBodyPrivateJwk0Alg `json:"alg,omitempty"` - D string `json:"d"` - Dp string `json:"dp"` - Dq string `json:"dq"` - E CreateSigningKeyBodyPrivateJwk0E `json:"e"` - Ext *CreateSigningKeyBodyPrivateJwk0Ext `json:"ext,omitempty"` - KeyOps *[]CreateSigningKeyBodyPrivateJwk0KeyOps `json:"key_ops,omitempty"` - Kid *openapi_types.UUID `json:"kid,omitempty"` - Kty CreateSigningKeyBodyPrivateJwk0Kty `json:"kty"` - N string `json:"n"` - P string `json:"p"` - Q string `json:"q"` - Qi string `json:"qi"` - Use *CreateSigningKeyBodyPrivateJwk0Use `json:"use,omitempty"` -} - -// CreateSigningKeyBodyPrivateJwk0Alg defines model for CreateSigningKeyBody.PrivateJwk.0.Alg. -type CreateSigningKeyBodyPrivateJwk0Alg string - -// CreateSigningKeyBodyPrivateJwk0E defines model for CreateSigningKeyBody.PrivateJwk.0.E. -type CreateSigningKeyBodyPrivateJwk0E string - -// CreateSigningKeyBodyPrivateJwk0Ext defines model for CreateSigningKeyBody.PrivateJwk.0.Ext. -type CreateSigningKeyBodyPrivateJwk0Ext bool - -// CreateSigningKeyBodyPrivateJwk0KeyOps defines model for CreateSigningKeyBody.PrivateJwk.0.KeyOps. -type CreateSigningKeyBodyPrivateJwk0KeyOps string - -// CreateSigningKeyBodyPrivateJwk0Kty defines model for CreateSigningKeyBody.PrivateJwk.0.Kty. -type CreateSigningKeyBodyPrivateJwk0Kty string - -// CreateSigningKeyBodyPrivateJwk0Use defines model for CreateSigningKeyBody.PrivateJwk.0.Use. -type CreateSigningKeyBodyPrivateJwk0Use string - -// CreateSigningKeyBodyPrivateJwk1 defines model for . -type CreateSigningKeyBodyPrivateJwk1 struct { - Alg *CreateSigningKeyBodyPrivateJwk1Alg `json:"alg,omitempty"` - Crv CreateSigningKeyBodyPrivateJwk1Crv `json:"crv"` - D string `json:"d"` - Ext *CreateSigningKeyBodyPrivateJwk1Ext `json:"ext,omitempty"` - KeyOps *[]CreateSigningKeyBodyPrivateJwk1KeyOps `json:"key_ops,omitempty"` - Kid *openapi_types.UUID `json:"kid,omitempty"` - Kty CreateSigningKeyBodyPrivateJwk1Kty `json:"kty"` - Use *CreateSigningKeyBodyPrivateJwk1Use `json:"use,omitempty"` - X string `json:"x"` - Y string `json:"y"` -} - -// CreateSigningKeyBodyPrivateJwk1Alg defines model for CreateSigningKeyBody.PrivateJwk.1.Alg. -type CreateSigningKeyBodyPrivateJwk1Alg string - -// CreateSigningKeyBodyPrivateJwk1Crv defines model for CreateSigningKeyBody.PrivateJwk.1.Crv. -type CreateSigningKeyBodyPrivateJwk1Crv string - -// CreateSigningKeyBodyPrivateJwk1Ext defines model for CreateSigningKeyBody.PrivateJwk.1.Ext. -type CreateSigningKeyBodyPrivateJwk1Ext bool - -// CreateSigningKeyBodyPrivateJwk1KeyOps defines model for CreateSigningKeyBody.PrivateJwk.1.KeyOps. -type CreateSigningKeyBodyPrivateJwk1KeyOps string - -// CreateSigningKeyBodyPrivateJwk1Kty defines model for CreateSigningKeyBody.PrivateJwk.1.Kty. -type CreateSigningKeyBodyPrivateJwk1Kty string - -// CreateSigningKeyBodyPrivateJwk1Use defines model for CreateSigningKeyBody.PrivateJwk.1.Use. -type CreateSigningKeyBodyPrivateJwk1Use string - -// CreateSigningKeyBodyPrivateJwk2 defines model for . -type CreateSigningKeyBodyPrivateJwk2 struct { - Alg *CreateSigningKeyBodyPrivateJwk2Alg `json:"alg,omitempty"` - Crv CreateSigningKeyBodyPrivateJwk2Crv `json:"crv"` - D string `json:"d"` - Ext *CreateSigningKeyBodyPrivateJwk2Ext `json:"ext,omitempty"` - KeyOps *[]CreateSigningKeyBodyPrivateJwk2KeyOps `json:"key_ops,omitempty"` - Kid *openapi_types.UUID `json:"kid,omitempty"` - Kty CreateSigningKeyBodyPrivateJwk2Kty `json:"kty"` - Use *CreateSigningKeyBodyPrivateJwk2Use `json:"use,omitempty"` - X string `json:"x"` -} - -// CreateSigningKeyBodyPrivateJwk2Alg defines model for CreateSigningKeyBody.PrivateJwk.2.Alg. -type CreateSigningKeyBodyPrivateJwk2Alg string - -// CreateSigningKeyBodyPrivateJwk2Crv defines model for CreateSigningKeyBody.PrivateJwk.2.Crv. -type CreateSigningKeyBodyPrivateJwk2Crv string - -// CreateSigningKeyBodyPrivateJwk2Ext defines model for CreateSigningKeyBody.PrivateJwk.2.Ext. -type CreateSigningKeyBodyPrivateJwk2Ext bool - -// CreateSigningKeyBodyPrivateJwk2KeyOps defines model for CreateSigningKeyBody.PrivateJwk.2.KeyOps. -type CreateSigningKeyBodyPrivateJwk2KeyOps string - -// CreateSigningKeyBodyPrivateJwk2Kty defines model for CreateSigningKeyBody.PrivateJwk.2.Kty. -type CreateSigningKeyBodyPrivateJwk2Kty string - -// CreateSigningKeyBodyPrivateJwk2Use defines model for CreateSigningKeyBody.PrivateJwk.2.Use. -type CreateSigningKeyBodyPrivateJwk2Use string - -// CreateSigningKeyBodyPrivateJwk3 defines model for . -type CreateSigningKeyBodyPrivateJwk3 struct { - Alg *CreateSigningKeyBodyPrivateJwk3Alg `json:"alg,omitempty"` - Ext *CreateSigningKeyBodyPrivateJwk3Ext `json:"ext,omitempty"` - K string `json:"k"` - KeyOps *[]CreateSigningKeyBodyPrivateJwk3KeyOps `json:"key_ops,omitempty"` - Kid *openapi_types.UUID `json:"kid,omitempty"` - Kty CreateSigningKeyBodyPrivateJwk3Kty `json:"kty"` - Use *CreateSigningKeyBodyPrivateJwk3Use `json:"use,omitempty"` -} - -// CreateSigningKeyBodyPrivateJwk3Alg defines model for CreateSigningKeyBody.PrivateJwk.3.Alg. -type CreateSigningKeyBodyPrivateJwk3Alg string - -// CreateSigningKeyBodyPrivateJwk3Ext defines model for CreateSigningKeyBody.PrivateJwk.3.Ext. -type CreateSigningKeyBodyPrivateJwk3Ext bool - -// CreateSigningKeyBodyPrivateJwk3KeyOps defines model for CreateSigningKeyBody.PrivateJwk.3.KeyOps. -type CreateSigningKeyBodyPrivateJwk3KeyOps string - -// CreateSigningKeyBodyPrivateJwk3Kty defines model for CreateSigningKeyBody.PrivateJwk.3.Kty. -type CreateSigningKeyBodyPrivateJwk3Kty string - -// CreateSigningKeyBodyPrivateJwk3Use defines model for CreateSigningKeyBody.PrivateJwk.3.Use. -type CreateSigningKeyBodyPrivateJwk3Use string - -// CreateSigningKeyBody_PrivateJwk defines model for CreateSigningKeyBody.PrivateJwk. -type CreateSigningKeyBody_PrivateJwk struct { - union json.RawMessage -} - -// CreateSigningKeyBodyStatus defines model for CreateSigningKeyBody.Status. -type CreateSigningKeyBodyStatus string - -// CreateThirdPartyAuthBody defines model for CreateThirdPartyAuthBody. -type CreateThirdPartyAuthBody struct { - CustomJwks interface{} `json:"custom_jwks,omitempty"` - JwksUrl *string `json:"jwks_url,omitempty"` - OidcIssuerUrl *string `json:"oidc_issuer_url,omitempty"` -} - -// DatabaseUpgradeStatusResponseOutput defines model for DatabaseUpgradeStatusResponse_Output. -type DatabaseUpgradeStatusResponseOutput struct { - DatabaseUpgradeStatus nullable.Nullable[struct { - Error *DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError `json:"error,omitempty"` - InitiatedAt string `json:"initiated_at"` - LatestStatusAt string `json:"latest_status_at"` - Progress *DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress `json:"progress,omitempty"` - Status float32 `json:"status"` - TargetVersion string `json:"target_version"` - }] `json:"databaseUpgradeStatus"` -} - -// DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError defines model for DatabaseUpgradeStatusResponseOutput.DatabaseUpgradeStatus.Error. -type DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusError string - -// DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress defines model for DatabaseUpgradeStatusResponseOutput.DatabaseUpgradeStatus.Progress. -type DatabaseUpgradeStatusResponseOutputDatabaseUpgradeStatusProgress string - -// DeleteProviderResponseOutput defines model for DeleteProviderResponse_Output. -type DeleteProviderResponseOutput struct { - CreatedAt *string `json:"created_at,omitempty"` - Domains *[]struct { - CreatedAt *string `json:"created_at,omitempty"` - Domain *string `json:"domain,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"domains,omitempty"` - Id string `json:"id"` - Saml *struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - EntityId string `json:"entity_id"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *string `json:"name_id_format,omitempty"` - } `json:"saml,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` -} - -// DeleteRolesResponseOutput defines model for DeleteRolesResponse_Output. -type DeleteRolesResponseOutput struct { - Message DeleteRolesResponseOutputMessage `json:"message"` -} - -// DeleteRolesResponseOutputMessage defines model for DeleteRolesResponseOutput.Message. -type DeleteRolesResponseOutputMessage string - -// DeleteSecretsBody defines model for DeleteSecretsBody. -type DeleteSecretsBody = []string - -// DeployFunctionResponseOutput defines model for DeployFunctionResponse_Output. -type DeployFunctionResponseOutput struct { - CreatedAt *int64 `json:"created_at,omitempty"` - EntrypointPath *string `json:"entrypoint_path,omitempty"` - EzbrSha256 *string `json:"ezbr_sha256,omitempty"` - Id string `json:"id"` - ImportMap *bool `json:"import_map,omitempty"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` - Status DeployFunctionResponseOutputStatus `json:"status"` - UpdatedAt *int64 `json:"updated_at,omitempty"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - Version int `json:"version"` -} - -// DeployFunctionResponseOutputStatus defines model for DeployFunctionResponseOutput.Status. -type DeployFunctionResponseOutputStatus string - -// DiskAutoscaleConfigOutput defines model for DiskAutoscaleConfig_Output. -type DiskAutoscaleConfigOutput struct { - // GrowthPercent Growth percentage for disk autoscaling - GrowthPercent nullable.Nullable[int] `json:"growth_percent"` - - // MaxSizeGb Maximum limit the disk size will grow to in GB - MaxSizeGb nullable.Nullable[int] `json:"max_size_gb"` - - // MinIncrementGb Minimum increment size for disk autoscaling in GB - MinIncrementGb nullable.Nullable[int] `json:"min_increment_gb"` -} - -// DiskRequestBody defines model for DiskRequestBody. -type DiskRequestBody struct { - Attributes DiskRequestBody_Attributes `json:"attributes"` -} - -// DiskRequestBodyAttributes0 defines model for . -type DiskRequestBodyAttributes0 struct { - Iops int `json:"iops"` - SizeGb int `json:"size_gb"` - ThroughputMibps *int `json:"throughput_mibps,omitempty"` - Type DiskRequestBodyAttributes0Type `json:"type"` -} - -// DiskRequestBodyAttributes0Type defines model for DiskRequestBody.Attributes.0.Type. -type DiskRequestBodyAttributes0Type string - -// DiskRequestBodyAttributes1 defines model for . -type DiskRequestBodyAttributes1 struct { - Iops int `json:"iops"` - SizeGb int `json:"size_gb"` - Type DiskRequestBodyAttributes1Type `json:"type"` -} - -// DiskRequestBodyAttributes1Type defines model for DiskRequestBody.Attributes.1.Type. -type DiskRequestBodyAttributes1Type string - -// DiskRequestBody_Attributes defines model for DiskRequestBody.Attributes. -type DiskRequestBody_Attributes struct { - union json.RawMessage -} - -// DiskResponseOutput defines model for DiskResponse_Output. -type DiskResponseOutput struct { - Attributes DiskResponseOutput_Attributes `json:"attributes"` - LastModifiedAt *string `json:"last_modified_at,omitempty"` -} - -// DiskResponseOutputAttributes0 defines model for . -type DiskResponseOutputAttributes0 struct { - Iops int `json:"iops"` - SizeGb int `json:"size_gb"` - ThroughputMibps *int `json:"throughput_mibps,omitempty"` - Type DiskResponseOutputAttributes0Type `json:"type"` -} - -// DiskResponseOutputAttributes0Type defines model for DiskResponseOutput.Attributes.0.Type. -type DiskResponseOutputAttributes0Type string - -// DiskResponseOutputAttributes1 defines model for . -type DiskResponseOutputAttributes1 struct { - Iops int `json:"iops"` - SizeGb int `json:"size_gb"` - Type DiskResponseOutputAttributes1Type `json:"type"` -} - -// DiskResponseOutputAttributes1Type defines model for DiskResponseOutput.Attributes.1.Type. -type DiskResponseOutputAttributes1Type string - -// DiskResponseOutput_Attributes defines model for DiskResponseOutput.Attributes. -type DiskResponseOutput_Attributes struct { - union json.RawMessage -} - -// DiskUtilMetricsResponseOutput defines model for DiskUtilMetricsResponse_Output. -type DiskUtilMetricsResponseOutput struct { - Metrics struct { - FsAvailBytes float32 `json:"fs_avail_bytes"` - FsSizeBytes float32 `json:"fs_size_bytes"` - FsUsedBytes float32 `json:"fs_used_bytes"` - } `json:"metrics"` - Timestamp string `json:"timestamp"` -} - -// FunctionDeployBody defines model for FunctionDeployBody. -type FunctionDeployBody struct { - File []openapi_types.File `json:"file"` - Metadata struct { - EntrypointPath string `json:"entrypoint_path"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name *string `json:"name,omitempty"` - StaticPatterns *[]string `json:"static_patterns,omitempty"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - } `json:"metadata"` -} - -// FunctionResponseOutput defines model for FunctionResponse_Output. -type FunctionResponseOutput struct { - CreatedAt int64 `json:"created_at"` - EntrypointPath *string `json:"entrypoint_path,omitempty"` - EzbrSha256 *string `json:"ezbr_sha256,omitempty"` - Id string `json:"id"` - ImportMap *bool `json:"import_map,omitempty"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` - Status FunctionResponseOutputStatus `json:"status"` - UpdatedAt int64 `json:"updated_at"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - Version int `json:"version"` -} - -// FunctionResponseOutputStatus defines model for FunctionResponseOutput.Status. -type FunctionResponseOutputStatus string - -// FunctionSlugResponseOutput defines model for FunctionSlugResponse_Output. -type FunctionSlugResponseOutput struct { - CreatedAt int64 `json:"created_at"` - EntrypointPath *string `json:"entrypoint_path,omitempty"` - EzbrSha256 *string `json:"ezbr_sha256,omitempty"` - Id string `json:"id"` - ImportMap *bool `json:"import_map,omitempty"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name string `json:"name"` - Slug string `json:"slug"` - Status FunctionSlugResponseOutputStatus `json:"status"` - UpdatedAt int64 `json:"updated_at"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - Version int `json:"version"` -} - -// FunctionSlugResponseOutputStatus defines model for FunctionSlugResponseOutput.Status. -type FunctionSlugResponseOutputStatus string - -// GetProjectAvailableRestoreVersionsResponseOutput defines model for GetProjectAvailableRestoreVersionsResponse_Output. -type GetProjectAvailableRestoreVersionsResponseOutput struct { - AvailableVersions []struct { - PostgresEngine GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine `json:"postgres_engine"` - ReleaseChannel GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel `json:"release_channel"` - Version string `json:"version"` - } `json:"available_versions"` -} - -// GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine defines model for GetProjectAvailableRestoreVersionsResponseOutput.AvailableVersions.PostgresEngine. -type GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsPostgresEngine string - -// GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel defines model for GetProjectAvailableRestoreVersionsResponseOutput.AvailableVersions.ReleaseChannel. -type GetProjectAvailableRestoreVersionsResponseOutputAvailableVersionsReleaseChannel string - -// GetProjectDbMetadataResponseOutput defines model for GetProjectDbMetadataResponse_Output. -type GetProjectDbMetadataResponseOutput struct { - Databases []struct { - Name string `json:"name"` - Schemas []struct { - Name string `json:"name"` - } `json:"schemas"` - } `json:"databases"` -} - -// GetProviderResponseOutput defines model for GetProviderResponse_Output. -type GetProviderResponseOutput struct { - CreatedAt *string `json:"created_at,omitempty"` - Domains *[]struct { - CreatedAt *string `json:"created_at,omitempty"` - Domain *string `json:"domain,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"domains,omitempty"` - Id string `json:"id"` - Saml *struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - EntityId string `json:"entity_id"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *string `json:"name_id_format,omitempty"` - } `json:"saml,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` -} - -// InviteExternalUserJitAccessBody defines model for InviteExternalUserJitAccessBody. -type InviteExternalUserJitAccessBody struct { - Email openapi_types.Email `json:"email"` - Roles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"roles"` -} - -// InviteExternalUserJitResponseOutput defines model for InviteExternalUserJitResponse_Output. -type InviteExternalUserJitResponseOutput struct { - Email openapi_types.Email `json:"email"` - InviteId openapi_types.UUID `json:"invite_id"` - UserRoles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"user_roles"` -} - -// JitAccessRequestRequest defines model for JitAccessRequestRequest. -type JitAccessRequestRequest struct { - State JitAccessRequestRequestState `json:"state"` -} - -// JitAccessRequestRequestState defines model for JitAccessRequestRequest.State. -type JitAccessRequestRequestState string - -// JitAccessResponseOutput defines model for JitAccessResponse_Output. -type JitAccessResponseOutput struct { - UserId *openapi_types.UUID `json:"user_id,omitempty"` - UserRoles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"user_roles"` -} - -// JitAuthorizeAccessResponseOutput defines model for JitAuthorizeAccessResponse_Output. -type JitAuthorizeAccessResponseOutput struct { - Act *string `json:"act,omitempty"` - UserId *openapi_types.UUID `json:"user_id,omitempty"` - UserRole struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"user_role"` -} - -// JitListAccessResponseOutput defines model for JitListAccessResponse_Output. -type JitListAccessResponseOutput struct { - Items []JitListAccessResponseOutput_Items_Item `json:"items"` -} - -// JitListAccessResponseOutputItems0 defines model for . -type JitListAccessResponseOutputItems0 struct { - ExpiresAt nullable.Nullable[string] `json:"expires_at"` - InviteId nullable.Nullable[openapi_types.UUID] `json:"invite_id"` - PrimaryEmail nullable.Nullable[string] `json:"primary_email"` - UserId openapi_types.UUID `json:"user_id"` - UserRoles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"user_roles"` -} - -// JitListAccessResponseOutputItems1 defines model for . -type JitListAccessResponseOutputItems1 struct { - ExpiresAt string `json:"expires_at"` - InviteId openapi_types.UUID `json:"invite_id"` - PrimaryEmail string `json:"primary_email"` - UserId nullable.Nullable[openapi_types.UUID] `json:"user_id"` - UserRoles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"user_roles"` -} - -// JitListAccessResponseOutput_Items_Item defines model for JitListAccessResponse_Output.items.Item. -type JitListAccessResponseOutput_Items_Item struct { - union json.RawMessage -} - -// JsonValueOutput Any JSON-serializable value -type JsonValueOutput struct { - union json.RawMessage -} - -// JsonValueOutput0 defines model for . -type JsonValueOutput0 struct { - union json.RawMessage -} - -// JsonValueOutput00 defines model for . -type JsonValueOutput00 = string - -// JsonValueOutput01 defines model for . -type JsonValueOutput01 = float32 - -// JsonValueOutput02 defines model for . -type JsonValueOutput02 = bool - -// JsonValueOutput1 defines model for . -type JsonValueOutput1 = []JsonValueOutput - -// JsonValueOutput2 defines model for . -type JsonValueOutput2 map[string]JsonValueOutput - -// LegacyApiKeysResponseOutput defines model for LegacyApiKeysResponse_Output. -type LegacyApiKeysResponseOutput struct { - Enabled bool `json:"enabled"` -} - -// ListActionRunResponseOutput defines model for ListActionRunResponse_Output. -type ListActionRunResponseOutput = []struct { - BranchId string `json:"branch_id"` - CheckRunId nullable.Nullable[float32] `json:"check_run_id"` - CreatedAt string `json:"created_at"` - GitConfig nullable.Nullable[interface{}] `json:"git_config,omitempty"` - Id string `json:"id"` - RunSteps []struct { - CreatedAt string `json:"created_at"` - Name ListActionRunResponseOutputRunStepsName `json:"name"` - Status ListActionRunResponseOutputRunStepsStatus `json:"status"` - UpdatedAt string `json:"updated_at"` - } `json:"run_steps"` - UpdatedAt string `json:"updated_at"` - Workdir nullable.Nullable[string] `json:"workdir"` -} - -// ListActionRunResponseOutputRunStepsName defines model for ListActionRunResponseOutput.RunSteps.Name. -type ListActionRunResponseOutputRunStepsName string - -// ListActionRunResponseOutputRunStepsStatus defines model for ListActionRunResponseOutput.RunSteps.Status. -type ListActionRunResponseOutputRunStepsStatus string - -// ListProjectAddonsResponseOutput defines model for ListProjectAddonsResponse_Output. -type ListProjectAddonsResponseOutput struct { - AvailableAddons []struct { - Name string `json:"name"` - Type ListProjectAddonsResponseOutputAvailableAddonsType `json:"type"` - Variants []struct { - Id ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id `json:"id"` - - // Meta Any JSON-serializable value - Meta *JsonValueOutput `json:"meta,omitempty"` - Name string `json:"name"` - Price struct { - Amount float32 `json:"amount"` - Description string `json:"description"` - Interval ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval `json:"interval"` - Type ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType `json:"type"` - } `json:"price"` - } `json:"variants"` - } `json:"available_addons"` - SelectedAddons []struct { - Type ListProjectAddonsResponseOutputSelectedAddonsType `json:"type"` - Variant struct { - Id ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id `json:"id"` - - // Meta Any JSON-serializable value - Meta *JsonValueOutput `json:"meta,omitempty"` - Name string `json:"name"` - Price struct { - Amount float32 `json:"amount"` - Description string `json:"description"` - Interval ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval `json:"interval"` - Type ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType `json:"type"` - } `json:"price"` - } `json:"variant"` - } `json:"selected_addons"` -} - -// ListProjectAddonsResponseOutputAvailableAddonsType defines model for ListProjectAddonsResponseOutput.AvailableAddons.Type. -type ListProjectAddonsResponseOutputAvailableAddonsType string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.0. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.1. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.2. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.3. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.4. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.5. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.6. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id.7. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 string - -// ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Id. -type ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id struct { - union json.RawMessage -} - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Price.Interval. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceInterval string - -// ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType defines model for ListProjectAddonsResponseOutput.AvailableAddons.Variants.Price.Type. -type ListProjectAddonsResponseOutputAvailableAddonsVariantsPriceType string - -// ListProjectAddonsResponseOutputSelectedAddonsType defines model for ListProjectAddonsResponseOutput.SelectedAddons.Type. -type ListProjectAddonsResponseOutputSelectedAddonsType string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId0 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.0. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId0 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId1 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.1. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId1 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId2 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.2. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId2 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId3 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.3. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId3 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId4 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.4. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId4 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId5 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.5. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId5 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId6 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.6. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId6 string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantId7 defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id.7. -type ListProjectAddonsResponseOutputSelectedAddonsVariantId7 string - -// ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Id. -type ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id struct { - union json.RawMessage -} - -// ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Price.Interval. -type ListProjectAddonsResponseOutputSelectedAddonsVariantPriceInterval string - -// ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType defines model for ListProjectAddonsResponseOutput.SelectedAddons.Variant.Price.Type. -type ListProjectAddonsResponseOutputSelectedAddonsVariantPriceType string - -// ListProvidersResponseOutput defines model for ListProvidersResponse_Output. -type ListProvidersResponseOutput struct { - Items []struct { - CreatedAt *string `json:"created_at,omitempty"` - Domains *[]struct { - CreatedAt *string `json:"created_at,omitempty"` - Domain *string `json:"domain,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"domains,omitempty"` - Id string `json:"id"` - Saml *struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - EntityId string `json:"entity_id"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *string `json:"name_id_format,omitempty"` - } `json:"saml,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"items"` -} - -// NetworkBanResponseEnrichedOutput defines model for NetworkBanResponseEnriched_Output. -type NetworkBanResponseEnrichedOutput struct { - BannedIpv4Addresses []struct { - BannedAddress string `json:"banned_address"` - Identifier string `json:"identifier"` - Type string `json:"type"` - } `json:"banned_ipv4_addresses"` -} - -// NetworkBanResponseOutput defines model for NetworkBanResponse_Output. -type NetworkBanResponseOutput struct { - BannedIpv4Addresses []string `json:"banned_ipv4_addresses"` -} - -// NetworkRestrictionsPatchRequest defines model for NetworkRestrictionsPatchRequest. -type NetworkRestrictionsPatchRequest struct { - Add *struct { - DbAllowedCidrs *[]string `json:"dbAllowedCidrs,omitempty"` - DbAllowedCidrsV6 *[]string `json:"dbAllowedCidrsV6,omitempty"` - } `json:"add,omitempty"` - Remove *struct { - DbAllowedCidrs *[]string `json:"dbAllowedCidrs,omitempty"` - DbAllowedCidrsV6 *[]string `json:"dbAllowedCidrsV6,omitempty"` - } `json:"remove,omitempty"` -} - -// NetworkRestrictionsRequest defines model for NetworkRestrictionsRequest. -type NetworkRestrictionsRequest struct { - DbAllowedCidrs *[]string `json:"dbAllowedCidrs,omitempty"` - DbAllowedCidrsV6 *[]string `json:"dbAllowedCidrsV6,omitempty"` -} - -// NetworkRestrictionsResponseOutput defines model for NetworkRestrictionsResponse_Output. -type NetworkRestrictionsResponseOutput struct { - AppliedAt *time.Time `json:"applied_at,omitempty"` - - // Config At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`. - Config struct { - DbAllowedCidrs *[]string `json:"dbAllowedCidrs,omitempty"` - DbAllowedCidrsV6 *[]string `json:"dbAllowedCidrsV6,omitempty"` - } `json:"config"` - Entitlement NetworkRestrictionsResponseOutputEntitlement `json:"entitlement"` - - // OldConfig Populated when a new config has been received, but not registered as successfully applied to a project. - OldConfig *struct { - DbAllowedCidrs *[]string `json:"dbAllowedCidrs,omitempty"` - DbAllowedCidrsV6 *[]string `json:"dbAllowedCidrsV6,omitempty"` - } `json:"old_config,omitempty"` - Status NetworkRestrictionsResponseOutputStatus `json:"status"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` -} - -// NetworkRestrictionsResponseOutputEntitlement defines model for NetworkRestrictionsResponseOutput.Entitlement. -type NetworkRestrictionsResponseOutputEntitlement string - -// NetworkRestrictionsResponseOutputStatus defines model for NetworkRestrictionsResponseOutput.Status. -type NetworkRestrictionsResponseOutputStatus string - -// NetworkRestrictionsV2ResponseOutput defines model for NetworkRestrictionsV2Response_Output. -type NetworkRestrictionsV2ResponseOutput struct { - AppliedAt *time.Time `json:"applied_at,omitempty"` - - // Config At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`. - Config struct { - DbAllowedCidrs *[]struct { - Address string `json:"address"` - Type NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType `json:"type"` - } `json:"dbAllowedCidrs,omitempty"` - } `json:"config"` - Entitlement NetworkRestrictionsV2ResponseOutputEntitlement `json:"entitlement"` - - // OldConfig Populated when a new config has been received, but not registered as successfully applied to a project. - OldConfig *struct { - DbAllowedCidrs *[]struct { - Address string `json:"address"` - Type NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType `json:"type"` - } `json:"dbAllowedCidrs,omitempty"` - } `json:"old_config,omitempty"` - Status NetworkRestrictionsV2ResponseOutputStatus `json:"status"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` -} - -// NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType defines model for NetworkRestrictionsV2ResponseOutput.Config.DbAllowedCidrs.Type. -type NetworkRestrictionsV2ResponseOutputConfigDbAllowedCidrsType string - -// NetworkRestrictionsV2ResponseOutputEntitlement defines model for NetworkRestrictionsV2ResponseOutput.Entitlement. -type NetworkRestrictionsV2ResponseOutputEntitlement string - -// NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType defines model for NetworkRestrictionsV2ResponseOutput.OldConfig.DbAllowedCidrs.Type. -type NetworkRestrictionsV2ResponseOutputOldConfigDbAllowedCidrsType string - -// NetworkRestrictionsV2ResponseOutputStatus defines model for NetworkRestrictionsV2ResponseOutput.Status. -type NetworkRestrictionsV2ResponseOutputStatus string - -// OAuthRevokeTokenBody defines model for OAuthRevokeTokenBody. -type OAuthRevokeTokenBody struct { - ClientId openapi_types.UUID `json:"client_id"` - ClientSecret string `json:"client_secret"` - RefreshToken string `json:"refresh_token"` -} - -// OAuthTokenBody defines model for OAuthTokenBody. -type OAuthTokenBody struct { - // Assertion IDJAG assertion JWT for grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. Beta - available on Team and Enterprise plans only. - Assertion *string `json:"assertion,omitempty"` - ClientId *openapi_types.UUID `json:"client_id,omitempty"` - ClientSecret *string `json:"client_secret,omitempty"` - Code *string `json:"code,omitempty"` - CodeVerifier *string `json:"code_verifier,omitempty"` - GrantType *OAuthTokenBodyGrantType `json:"grant_type,omitempty"` - RedirectUri *string `json:"redirect_uri,omitempty"` - RefreshToken *string `json:"refresh_token,omitempty"` - - // Resource Resource indicator for MCP (Model Context Protocol) clients - Resource *string `json:"resource,omitempty"` - Scope *string `json:"scope,omitempty"` -} - -// OAuthTokenBodyGrantType defines model for OAuthTokenBody.GrantType. -type OAuthTokenBodyGrantType string - -// OAuthTokenResponseOutput defines model for OAuthTokenResponse_Output. -type OAuthTokenResponseOutput struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - - // RefreshToken The `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type issues access tokens only, no refresh token is returned and the token cannot be revoked via `/v1/oauth/revoke`. - RefreshToken *string `json:"refresh_token,omitempty"` - TokenType OAuthTokenResponseOutputTokenType `json:"token_type"` -} - -// OAuthTokenResponseOutputTokenType defines model for OAuthTokenResponseOutput.TokenType. -type OAuthTokenResponseOutputTokenType string - -// OrganizationProjectClaimResponseOutput defines model for OrganizationProjectClaimResponse_Output. -type OrganizationProjectClaimResponseOutput struct { - CreatedAt string `json:"created_at"` - CreatedBy openapi_types.UUID `json:"created_by"` - ExpiresAt string `json:"expires_at"` - Preview struct { - Errors []struct { - Key string `json:"key"` - Message string `json:"message"` - } `json:"errors"` - Info []struct { - Key string `json:"key"` - Message string `json:"message"` - } `json:"info"` - MembersExceedingFreeProjectLimit []struct { - Limit float32 `json:"limit"` - Name string `json:"name"` - } `json:"members_exceeding_free_project_limit"` - SourceSubscriptionPlan OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan `json:"source_subscription_plan"` - TargetSubscriptionPlan nullable.Nullable[OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan] `json:"target_subscription_plan"` - Valid bool `json:"valid"` - Warnings []struct { - Key string `json:"key"` - Message string `json:"message"` - } `json:"warnings"` - } `json:"preview"` - Project struct { - Name string `json:"name"` - Ref string `json:"ref"` - } `json:"project"` -} - -// OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan defines model for OrganizationProjectClaimResponseOutput.Preview.SourceSubscriptionPlan. -type OrganizationProjectClaimResponseOutputPreviewSourceSubscriptionPlan string - -// OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan defines model for OrganizationProjectClaimResponseOutput.Preview.TargetSubscriptionPlan. -type OrganizationProjectClaimResponseOutputPreviewTargetSubscriptionPlan string - -// OrganizationProjectsResponseOutput defines model for OrganizationProjectsResponse_Output. -type OrganizationProjectsResponseOutput struct { - Pagination struct { - // Count Total number of projects. Use this to calculate the total number of pages. - Count float32 `json:"count"` - - // Limit Maximum number of projects per page - Limit float32 `json:"limit"` - - // Offset Number of projects skipped in this response - Offset float32 `json:"offset"` - } `json:"pagination"` - Projects []struct { - CloudProvider string `json:"cloud_provider"` - Databases []struct { - CloudProvider string `json:"cloud_provider"` - DiskLastModifiedAt *string `json:"disk_last_modified_at,omitempty"` - DiskThroughputMbps *float32 `json:"disk_throughput_mbps,omitempty"` - DiskType *OrganizationProjectsResponseOutputProjectsDatabasesDiskType `json:"disk_type,omitempty"` - DiskVolumeSizeGb *float32 `json:"disk_volume_size_gb,omitempty"` - Identifier string `json:"identifier"` - InfraComputeSize *OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize `json:"infra_compute_size,omitempty"` - Region string `json:"region"` - Status OrganizationProjectsResponseOutputProjectsDatabasesStatus `json:"status"` - Type OrganizationProjectsResponseOutputProjectsDatabasesType `json:"type"` - } `json:"databases"` - InsertedAt string `json:"inserted_at"` - IsBranch bool `json:"is_branch"` - Name string `json:"name"` - Ref string `json:"ref"` - Region string `json:"region"` - Status OrganizationProjectsResponseOutputProjectsStatus `json:"status"` - } `json:"projects"` -} - -// OrganizationProjectsResponseOutputProjectsDatabasesDiskType defines model for OrganizationProjectsResponseOutput.Projects.Databases.DiskType. -type OrganizationProjectsResponseOutputProjectsDatabasesDiskType string - -// OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize defines model for OrganizationProjectsResponseOutput.Projects.Databases.InfraComputeSize. -type OrganizationProjectsResponseOutputProjectsDatabasesInfraComputeSize string - -// OrganizationProjectsResponseOutputProjectsDatabasesStatus defines model for OrganizationProjectsResponseOutput.Projects.Databases.Status. -type OrganizationProjectsResponseOutputProjectsDatabasesStatus string - -// OrganizationProjectsResponseOutputProjectsDatabasesType defines model for OrganizationProjectsResponseOutput.Projects.Databases.Type. -type OrganizationProjectsResponseOutputProjectsDatabasesType string - -// OrganizationProjectsResponseOutputProjectsStatus defines model for OrganizationProjectsResponseOutput.Projects.Status. -type OrganizationProjectsResponseOutputProjectsStatus string - -// OrganizationResponseV1Output defines model for OrganizationResponseV1_Output. -type OrganizationResponseV1Output struct { - // Id Deprecated: Use `slug` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Id string `json:"id"` - Name string `json:"name"` - - // Slug Organization slug - Slug string `json:"slug"` -} - -// PgsodiumConfigResponseOutput defines model for PgsodiumConfigResponse_Output. -type PgsodiumConfigResponseOutput struct { - // RootKey The pgsodium root key: 32 bytes, hex-encoded (64 characters). - RootKey string `json:"root_key"` -} - -// PlanGateErrorBody defines model for PlanGateErrorBody. -type PlanGateErrorBody struct { - // Error Present on entitlement denials. Other errors with this status code (validation, billing state) carry only message. - Error *struct { - // Code Machine-readable marker for plan-gated denials - Code PlanGateErrorBodyErrorCode `json:"code"` - - // Feature Entitlement feature key that failed the check - Feature string `json:"feature"` - - // UpgradeUrl Billing page URL for the organization, present when the org is resolvable - UpgradeUrl *string `json:"upgrade_url,omitempty"` - } `json:"error,omitempty"` - - // Message Human-readable explanation of the plan gate - Message string `json:"message"` -} - -// PlanGateErrorBodyErrorCode Machine-readable marker for plan-gated denials -type PlanGateErrorBodyErrorCode string - -// PostgresConfigResponseOutput defines model for PostgresConfigResponse_Output. -type PostgresConfigResponseOutput struct { - AutovacuumMaxWorkers *int `json:"autovacuum_max_workers,omitempty"` - AutovacuumWorkMem *string `json:"autovacuum_work_mem,omitempty"` - - // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - CronLogStatement *bool `json:"cron.log_statement,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` - - // LogAutovacuumMinDuration Default unit: ms - LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` - LogCheckpoints *bool `json:"log_checkpoints,omitempty"` - LogConnections *bool `json:"log_connections,omitempty"` - LogDisconnections *bool `json:"log_disconnections,omitempty"` - LogDuration *bool `json:"log_duration,omitempty"` - LogLockWaits *bool `json:"log_lock_waits,omitempty"` - LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` - LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` - - // LogStartupProgressInterval Default unit: ms - LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` - LogTempFiles *string `json:"log_temp_files,omitempty"` - LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` - MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` - MaxConnections *int `json:"max_connections,omitempty"` - MaxLocksPerTransaction *int `json:"max_locks_per_transaction,omitempty"` - MaxLogicalReplicationWorkers *int `json:"max_logical_replication_workers,omitempty"` - MaxParallelMaintenanceWorkers *int `json:"max_parallel_maintenance_workers,omitempty"` - MaxParallelWorkers *int `json:"max_parallel_workers,omitempty"` - MaxParallelWorkersPerGather *int `json:"max_parallel_workers_per_gather,omitempty"` - MaxReplicationSlots *int `json:"max_replication_slots,omitempty"` - MaxSlotWalKeepSize *string `json:"max_slot_wal_keep_size,omitempty"` - MaxStandbyArchiveDelay *string `json:"max_standby_archive_delay,omitempty"` - MaxStandbyStreamingDelay *string `json:"max_standby_streaming_delay,omitempty"` - MaxSyncWorkersPerSubscription *int `json:"max_sync_workers_per_subscription,omitempty"` - MaxWalSenders *int `json:"max_wal_senders,omitempty"` - MaxWalSize *string `json:"max_wal_size,omitempty"` - MaxWorkerProcesses *int `json:"max_worker_processes,omitempty"` - PgNetBatchSize *int `json:"pg_net.batch_size,omitempty"` - PgStatStatementsMax *int `json:"pg_stat_statements.max,omitempty"` - SessionReplicationRole *PostgresConfigResponseOutputSessionReplicationRole `json:"session_replication_role,omitempty"` - SharedBuffers *string `json:"shared_buffers,omitempty"` - - // StatementTimeout Default unit: ms - StatementTimeout *string `json:"statement_timeout,omitempty"` - TrackActivityQuerySize *string `json:"track_activity_query_size,omitempty"` - TrackCommitTimestamp *bool `json:"track_commit_timestamp,omitempty"` - WalKeepSize *string `json:"wal_keep_size,omitempty"` - - // WalSenderTimeout Default unit: ms - WalSenderTimeout *string `json:"wal_sender_timeout,omitempty"` - WorkMem *string `json:"work_mem,omitempty"` -} - -// PostgresConfigResponseOutputSessionReplicationRole defines model for PostgresConfigResponseOutput.SessionReplicationRole. -type PostgresConfigResponseOutputSessionReplicationRole string - -// PostgrestConfigWithJWTSecretResponseOutput defines model for PostgrestConfigWithJWTSecretResponse_Output. -type PostgrestConfigWithJWTSecretResponseOutput struct { - DbExtraSearchPath string `json:"db_extra_search_path"` - - // DbPool If `null`, the value is automatically configured based on compute size. - DbPool nullable.Nullable[int] `json:"db_pool"` - - // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. - DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` - DbSchema string `json:"db_schema"` - JwtSecret *string `json:"jwt_secret,omitempty"` - MaxRows int `json:"max_rows"` -} - -// ProjectClaimTokenResponseOutput defines model for ProjectClaimTokenResponse_Output. -type ProjectClaimTokenResponseOutput struct { - CreatedAt string `json:"created_at"` - CreatedBy openapi_types.UUID `json:"created_by"` - ExpiresAt string `json:"expires_at"` - TokenAlias string `json:"token_alias"` -} - -// ProjectUpgradeEligibilityResponseOutput defines model for ProjectUpgradeEligibilityResponse_Output. -type ProjectUpgradeEligibilityResponseOutput struct { - CurrentAppVersion string `json:"current_app_version"` - CurrentAppVersionReleaseChannel ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel `json:"current_app_version_release_channel"` - DurationEstimateHours float32 `json:"duration_estimate_hours"` - Eligible bool `json:"eligible"` - LatestAppVersion string `json:"latest_app_version"` - LegacyAuthCustomRoles []string `json:"legacy_auth_custom_roles"` - - // ObjectsToBeDropped Use validation_errors instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - ObjectsToBeDropped []string `json:"objects_to_be_dropped"` - TargetUpgradeVersions []struct { - AppVersion string `json:"app_version"` - PostgresVersion ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion `json:"postgres_version"` - ReleaseChannel ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel `json:"release_channel"` - } `json:"target_upgrade_versions"` - - // UnsupportedExtensions Use validation_errors instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - UnsupportedExtensions []string `json:"unsupported_extensions"` - - // UserDefinedObjectsInInternalSchemas Use validation_errors instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - UserDefinedObjectsInInternalSchemas []string `json:"user_defined_objects_in_internal_schemas"` - ValidationErrors []ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item `json:"validation_errors"` - Warnings []ProjectUpgradeEligibilityResponseOutput_Warnings_Item `json:"warnings"` -} - -// ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel defines model for ProjectUpgradeEligibilityResponseOutput.CurrentAppVersionReleaseChannel. -type ProjectUpgradeEligibilityResponseOutputCurrentAppVersionReleaseChannel string - -// ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion defines model for ProjectUpgradeEligibilityResponseOutput.TargetUpgradeVersions.PostgresVersion. -type ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsPostgresVersion string - -// ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel defines model for ProjectUpgradeEligibilityResponseOutput.TargetUpgradeVersions.ReleaseChannel. -type ProjectUpgradeEligibilityResponseOutputTargetUpgradeVersionsReleaseChannel string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors0 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors0 struct { - Dependents []string `json:"dependents"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors0Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors0Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.0.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors0Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors1 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors1 struct { - IndexName string `json:"index_name"` - SchemaName string `json:"schema_name"` - TableName string `json:"table_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors1Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors1Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.1.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors1Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors2 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors2 struct { - FunctionName string `json:"function_name"` - LangName string `json:"lang_name"` - SchemaName string `json:"schema_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors2Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors2Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.2.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors2Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors3 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors3 struct { - ExtensionName string `json:"extension_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors3Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors3Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.3.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors3Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors4 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors4 struct { - FdwHandlerName string `json:"fdw_handler_name"` - FdwName string `json:"fdw_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors4Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors4Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.4.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors4Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors5 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors5 struct { - SchemaName string `json:"schema_name"` - SequenceName string `json:"sequence_name"` - TableName string `json:"table_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors5Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors5Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.5.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors5Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors6 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors6 struct { - ObjName string `json:"obj_name"` - ObjType ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType `json:"obj_type"` - SchemaName string `json:"schema_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors6Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.6.ObjType.0. -type ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.6.ObjType.1. -type ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 string - -// ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.6.ObjType. -type ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType struct { - union json.RawMessage -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors6Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.6.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors6Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors7 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors7 struct { - SlotName string `json:"slot_name"` - Type ProjectUpgradeEligibilityResponseOutputValidationErrors7Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors7Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.7.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors7Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors8 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors8 struct { - Type ProjectUpgradeEligibilityResponseOutputValidationErrors8Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors8Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.8.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors8Type string - -// ProjectUpgradeEligibilityResponseOutputValidationErrors9 defines model for . -type ProjectUpgradeEligibilityResponseOutputValidationErrors9 struct { - Type ProjectUpgradeEligibilityResponseOutputValidationErrors9Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputValidationErrors9Type defines model for ProjectUpgradeEligibilityResponseOutput.ValidationErrors.9.Type. -type ProjectUpgradeEligibilityResponseOutputValidationErrors9Type string - -// ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item defines model for ProjectUpgradeEligibilityResponse_Output.validation_errors.Item. -type ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item struct { - union json.RawMessage -} - -// ProjectUpgradeEligibilityResponseOutputWarnings0 defines model for . -type ProjectUpgradeEligibilityResponseOutputWarnings0 struct { - Type ProjectUpgradeEligibilityResponseOutputWarnings0Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputWarnings0Type defines model for ProjectUpgradeEligibilityResponseOutput.Warnings.0.Type. -type ProjectUpgradeEligibilityResponseOutputWarnings0Type string - -// ProjectUpgradeEligibilityResponseOutputWarnings1 defines model for . -type ProjectUpgradeEligibilityResponseOutputWarnings1 struct { - Type ProjectUpgradeEligibilityResponseOutputWarnings1Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputWarnings1Type defines model for ProjectUpgradeEligibilityResponseOutput.Warnings.1.Type. -type ProjectUpgradeEligibilityResponseOutputWarnings1Type string - -// ProjectUpgradeEligibilityResponseOutputWarnings2 defines model for . -type ProjectUpgradeEligibilityResponseOutputWarnings2 struct { - Type ProjectUpgradeEligibilityResponseOutputWarnings2Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputWarnings2Type defines model for ProjectUpgradeEligibilityResponseOutput.Warnings.2.Type. -type ProjectUpgradeEligibilityResponseOutputWarnings2Type string - -// ProjectUpgradeEligibilityResponseOutputWarnings3 defines model for . -type ProjectUpgradeEligibilityResponseOutputWarnings3 struct { - Type ProjectUpgradeEligibilityResponseOutputWarnings3Type `json:"type"` -} - -// ProjectUpgradeEligibilityResponseOutputWarnings3Type defines model for ProjectUpgradeEligibilityResponseOutput.Warnings.3.Type. -type ProjectUpgradeEligibilityResponseOutputWarnings3Type string - -// ProjectUpgradeEligibilityResponseOutput_Warnings_Item defines model for ProjectUpgradeEligibilityResponse_Output.warnings.Item. -type ProjectUpgradeEligibilityResponseOutput_Warnings_Item struct { - union json.RawMessage -} - -// ProjectUpgradeInitiateResponseOutput defines model for ProjectUpgradeInitiateResponse_Output. -type ProjectUpgradeInitiateResponseOutput struct { - TrackingId string `json:"tracking_id"` -} - -// ReadOnlyStatusResponseOutput defines model for ReadOnlyStatusResponse_Output. -type ReadOnlyStatusResponseOutput struct { - Enabled bool `json:"enabled"` - OverrideActiveUntil string `json:"override_active_until"` - OverrideEnabled bool `json:"override_enabled"` -} - -// RealtimeConfigResponseOutput defines model for RealtimeConfigResponse_Output. -type RealtimeConfigResponseOutput struct { - // AdminSuspendedAt If set, the Realtime service has been suspended by an admin. - AdminSuspendedAt nullable.Nullable[time.Time] `json:"admin_suspended_at"` - - // ConnectionPool Sets connection pool size for Realtime Authorization - ConnectionPool nullable.Nullable[int] `json:"connection_pool"` - - // MaxBytesPerSecond Sets maximum number of bytes per second rate per channel limit - MaxBytesPerSecond nullable.Nullable[int] `json:"max_bytes_per_second"` - - // MaxChannelsPerClient Sets maximum number of channels per client rate limit - MaxChannelsPerClient nullable.Nullable[int] `json:"max_channels_per_client"` - - // MaxConcurrentUsers Sets maximum number of concurrent users rate limit - MaxConcurrentUsers nullable.Nullable[int] `json:"max_concurrent_users"` - - // MaxEventsPerSecond Sets maximum number of events per second rate per channel limit - MaxEventsPerSecond nullable.Nullable[int] `json:"max_events_per_second"` - - // MaxJoinsPerSecond Sets maximum number of joins per second rate limit - MaxJoinsPerSecond nullable.Nullable[int] `json:"max_joins_per_second"` - - // MaxPayloadSizeInKb Sets maximum number of payload size in KB rate limit - MaxPayloadSizeInKb nullable.Nullable[int] `json:"max_payload_size_in_kb"` - - // MaxPresenceEventsPerSecond Sets maximum number of presence events per second rate limit - MaxPresenceEventsPerSecond nullable.Nullable[int] `json:"max_presence_events_per_second"` - - // PostgresChangesPool Sets connection pool size used to create Postgres Changes subscriptions - PostgresChangesPool nullable.Nullable[int] `json:"postgres_changes_pool"` - - // PresenceEnabled Whether to enable presence - PresenceEnabled bool `json:"presence_enabled"` - - // PrivateOnly Whether to only allow private channels - PrivateOnly nullable.Nullable[bool] `json:"private_only"` - - // Suspend Disables the Realtime service for this project when true. Set to false to re-enable it. - Suspend nullable.Nullable[bool] `json:"suspend"` -} - -// RegionsInfoV1Output defines model for RegionsInfoV1_Output. -type RegionsInfoV1Output struct { - All struct { - SmartGroup []struct { - Code RegionsInfoV1OutputAllSmartGroupCode `json:"code"` - Name string `json:"name"` - Type RegionsInfoV1OutputAllSmartGroupType `json:"type"` - } `json:"smartGroup"` - Specific []struct { - Code RegionsInfoV1OutputAllSpecificCode `json:"code"` - Name string `json:"name"` - Provider RegionsInfoV1OutputAllSpecificProvider `json:"provider"` - Status *RegionsInfoV1OutputAllSpecificStatus `json:"status,omitempty"` - Type RegionsInfoV1OutputAllSpecificType `json:"type"` - } `json:"specific"` - } `json:"all"` - Recommendations struct { - SmartGroup struct { - Code RegionsInfoV1OutputRecommendationsSmartGroupCode `json:"code"` - Name string `json:"name"` - Type RegionsInfoV1OutputRecommendationsSmartGroupType `json:"type"` - } `json:"smartGroup"` - Specific []struct { - Code RegionsInfoV1OutputRecommendationsSpecificCode `json:"code"` - Name string `json:"name"` - Provider RegionsInfoV1OutputRecommendationsSpecificProvider `json:"provider"` - Status *RegionsInfoV1OutputRecommendationsSpecificStatus `json:"status,omitempty"` - Type RegionsInfoV1OutputRecommendationsSpecificType `json:"type"` - } `json:"specific"` - } `json:"recommendations"` -} - -// RegionsInfoV1OutputAllSmartGroupCode defines model for RegionsInfoV1Output.All.SmartGroup.Code. -type RegionsInfoV1OutputAllSmartGroupCode string - -// RegionsInfoV1OutputAllSmartGroupType defines model for RegionsInfoV1Output.All.SmartGroup.Type. -type RegionsInfoV1OutputAllSmartGroupType string - -// RegionsInfoV1OutputAllSpecificCode defines model for RegionsInfoV1Output.All.Specific.Code. -type RegionsInfoV1OutputAllSpecificCode string - -// RegionsInfoV1OutputAllSpecificProvider defines model for RegionsInfoV1Output.All.Specific.Provider. -type RegionsInfoV1OutputAllSpecificProvider string - -// RegionsInfoV1OutputAllSpecificStatus defines model for RegionsInfoV1Output.All.Specific.Status. -type RegionsInfoV1OutputAllSpecificStatus string - -// RegionsInfoV1OutputAllSpecificType defines model for RegionsInfoV1Output.All.Specific.Type. -type RegionsInfoV1OutputAllSpecificType string - -// RegionsInfoV1OutputRecommendationsSmartGroupCode defines model for RegionsInfoV1Output.Recommendations.SmartGroup.Code. -type RegionsInfoV1OutputRecommendationsSmartGroupCode string - -// RegionsInfoV1OutputRecommendationsSmartGroupType defines model for RegionsInfoV1Output.Recommendations.SmartGroup.Type. -type RegionsInfoV1OutputRecommendationsSmartGroupType string - -// RegionsInfoV1OutputRecommendationsSpecificCode defines model for RegionsInfoV1Output.Recommendations.Specific.Code. -type RegionsInfoV1OutputRecommendationsSpecificCode string - -// RegionsInfoV1OutputRecommendationsSpecificProvider defines model for RegionsInfoV1Output.Recommendations.Specific.Provider. -type RegionsInfoV1OutputRecommendationsSpecificProvider string - -// RegionsInfoV1OutputRecommendationsSpecificStatus defines model for RegionsInfoV1Output.Recommendations.Specific.Status. -type RegionsInfoV1OutputRecommendationsSpecificStatus string - -// RegionsInfoV1OutputRecommendationsSpecificType defines model for RegionsInfoV1Output.Recommendations.Specific.Type. -type RegionsInfoV1OutputRecommendationsSpecificType string - -// RemoveNetworkBanRequest defines model for RemoveNetworkBanRequest. -type RemoveNetworkBanRequest struct { - Identifier *string `json:"identifier,omitempty"` - - // Ipv4Addresses List of IP addresses to unban. - Ipv4Addresses []string `json:"ipv4_addresses"` - - // RequesterIp Include requester's public IP in the list of addresses to unban. - RequesterIp *bool `json:"requester_ip,omitempty"` -} - -// RemoveReadReplicaBody defines model for RemoveReadReplicaBody. -type RemoveReadReplicaBody struct { - DatabaseIdentifier string `json:"database_identifier"` -} - -// SecretResponseOutput defines model for SecretResponse_Output. -type SecretResponseOutput struct { - Name string `json:"name"` - UpdatedAt *string `json:"updated_at,omitempty"` - Value string `json:"value"` -} - -// SetUpReadReplicaBody defines model for SetUpReadReplicaBody. -type SetUpReadReplicaBody struct { - // ReadReplicaRegion Region you want your read replica to reside in - ReadReplicaRegion SetUpReadReplicaBodyReadReplicaRegion `json:"read_replica_region"` -} - -// SetUpReadReplicaBodyReadReplicaRegion Region you want your read replica to reside in -type SetUpReadReplicaBodyReadReplicaRegion string - -// SigningKeyResponseOutput defines model for SigningKeyResponse_Output. -type SigningKeyResponseOutput struct { - Algorithm SigningKeyResponseOutputAlgorithm `json:"algorithm"` - CreatedAt time.Time `json:"created_at"` - Id openapi_types.UUID `json:"id"` - PublicJwk nullable.Nullable[interface{}] `json:"public_jwk"` - Status SigningKeyResponseOutputStatus `json:"status"` - UpdatedAt time.Time `json:"updated_at"` -} - -// SigningKeyResponseOutputAlgorithm defines model for SigningKeyResponseOutput.Algorithm. -type SigningKeyResponseOutputAlgorithm string - -// SigningKeyResponseOutputStatus defines model for SigningKeyResponseOutput.Status. -type SigningKeyResponseOutputStatus string - -// SigningKeysResponseOutput defines model for SigningKeysResponse_Output. -type SigningKeysResponseOutput struct { - Keys []struct { - Algorithm SigningKeysResponseOutputKeysAlgorithm `json:"algorithm"` - CreatedAt time.Time `json:"created_at"` - Id openapi_types.UUID `json:"id"` - PublicJwk nullable.Nullable[interface{}] `json:"public_jwk"` - Status SigningKeysResponseOutputKeysStatus `json:"status"` - UpdatedAt time.Time `json:"updated_at"` - } `json:"keys"` -} - -// SigningKeysResponseOutputKeysAlgorithm defines model for SigningKeysResponseOutput.Keys.Algorithm. -type SigningKeysResponseOutputKeysAlgorithm string - -// SigningKeysResponseOutputKeysStatus defines model for SigningKeysResponseOutput.Keys.Status. -type SigningKeysResponseOutputKeysStatus string - -// SnippetListOutput defines model for SnippetList_Output. -type SnippetListOutput struct { - Cursor *string `json:"cursor,omitempty"` - Data []struct { - Description nullable.Nullable[string] `json:"description"` - Favorite bool `json:"favorite"` - Id string `json:"id"` - InsertedAt string `json:"inserted_at"` - Name string `json:"name"` - Owner struct { - Id float32 `json:"id"` - Username string `json:"username"` - } `json:"owner"` - Project struct { - Id float32 `json:"id"` - Name string `json:"name"` - } `json:"project"` - Type SnippetListOutputDataType `json:"type"` - UpdatedAt string `json:"updated_at"` - UpdatedBy struct { - Id float32 `json:"id"` - Username string `json:"username"` - } `json:"updated_by"` - Visibility SnippetListOutputDataVisibility `json:"visibility"` - } `json:"data"` -} - -// SnippetListOutputDataType defines model for SnippetListOutput.Data.Type. -type SnippetListOutputDataType string - -// SnippetListOutputDataVisibility defines model for SnippetListOutput.Data.Visibility. -type SnippetListOutputDataVisibility string - -// SnippetResponseOutput defines model for SnippetResponse_Output. -type SnippetResponseOutput struct { - Content struct { - // Favorite Deprecated: Rely on root-level favorite property instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Favorite *bool `json:"favorite,omitempty"` - SchemaVersion *string `json:"schema_version,omitempty"` - Sql string `json:"sql"` - } `json:"content"` - Description nullable.Nullable[string] `json:"description"` - Favorite bool `json:"favorite"` - Id string `json:"id"` - InsertedAt string `json:"inserted_at"` - Name string `json:"name"` - Owner struct { - Id float32 `json:"id"` - Username string `json:"username"` - } `json:"owner"` - Project struct { - Id float32 `json:"id"` - Name string `json:"name"` - } `json:"project"` - Type SnippetResponseOutputType `json:"type"` - UpdatedAt string `json:"updated_at"` - UpdatedBy struct { - Id float32 `json:"id"` - Username string `json:"username"` - } `json:"updated_by"` - Visibility SnippetResponseOutputVisibility `json:"visibility"` -} - -// SnippetResponseOutputType defines model for SnippetResponseOutput.Type. -type SnippetResponseOutputType string - -// SnippetResponseOutputVisibility defines model for SnippetResponseOutput.Visibility. -type SnippetResponseOutputVisibility string - -// SslEnforcementRequest defines model for SslEnforcementRequest. -type SslEnforcementRequest struct { - RequestedConfig struct { - Database bool `json:"database"` - } `json:"requestedConfig"` -} - -// SslEnforcementResponseOutput defines model for SslEnforcementResponse_Output. -type SslEnforcementResponseOutput struct { - AppliedSuccessfully bool `json:"appliedSuccessfully"` - CurrentConfig struct { - Database bool `json:"database"` - } `json:"currentConfig"` -} - -// StorageConfigResponseOutput defines model for StorageConfigResponse_Output. -type StorageConfigResponseOutput struct { - Capabilities struct { - IcebergCatalog bool `json:"iceberg_catalog"` - ListV2 bool `json:"list_v2"` - ObjectVersioning bool `json:"object_versioning"` - } `json:"capabilities"` - External struct { - UpstreamTarget StorageConfigResponseOutputExternalUpstreamTarget `json:"upstreamTarget"` - } `json:"external"` - Features struct { - IcebergCatalog struct { - Enabled bool `json:"enabled"` - MaxCatalogs int `json:"maxCatalogs"` - MaxNamespaces int `json:"maxNamespaces"` - MaxTables int `json:"maxTables"` - } `json:"icebergCatalog"` - ImageTransformation struct { - Enabled bool `json:"enabled"` - } `json:"imageTransformation"` - PurgeCache struct { - Enabled bool `json:"enabled"` - } `json:"purgeCache"` - S3Protocol struct { - Enabled bool `json:"enabled"` - } `json:"s3Protocol"` - VectorBuckets struct { - Enabled bool `json:"enabled"` - MaxBuckets int `json:"maxBuckets"` - MaxIndexes int `json:"maxIndexes"` - } `json:"vectorBuckets"` - } `json:"features"` - FileSizeLimit int64 `json:"fileSizeLimit"` - MigrationVersion nullable.Nullable[string] `json:"migrationVersion"` -} - -// StorageConfigResponseOutputExternalUpstreamTarget defines model for StorageConfigResponseOutput.External.UpstreamTarget. -type StorageConfigResponseOutputExternalUpstreamTarget string - -// StreamableFile defines model for StreamableFile. -type StreamableFile = map[string]interface{} - -// SubdomainAvailabilityResponseOutput defines model for SubdomainAvailabilityResponse_Output. -type SubdomainAvailabilityResponseOutput struct { - Available bool `json:"available"` -} - -// SupavisorConfigResponseOutput defines model for SupavisorConfigResponse_Output. -type SupavisorConfigResponseOutput struct { - ConnectionString string `json:"connection_string"` - DatabaseType SupavisorConfigResponseOutputDatabaseType `json:"database_type"` - DbHost string `json:"db_host"` - DbName string `json:"db_name"` - DbPort int `json:"db_port"` - DbUser string `json:"db_user"` - DefaultPoolSize nullable.Nullable[int] `json:"default_pool_size"` - Identifier string `json:"identifier"` - IsUsingScramAuth bool `json:"is_using_scram_auth"` - MaxClientConn nullable.Nullable[int] `json:"max_client_conn"` - PoolMode SupavisorConfigResponseOutputPoolMode `json:"pool_mode"` -} - -// SupavisorConfigResponseOutputDatabaseType defines model for SupavisorConfigResponseOutput.DatabaseType. -type SupavisorConfigResponseOutputDatabaseType string - -// SupavisorConfigResponseOutputPoolMode defines model for SupavisorConfigResponseOutput.PoolMode. -type SupavisorConfigResponseOutputPoolMode string - -// ThirdPartyAuthOutput defines model for ThirdPartyAuth_Output. -type ThirdPartyAuthOutput struct { - CustomJwks nullable.Nullable[interface{}] `json:"custom_jwks,omitempty"` - Id openapi_types.UUID `json:"id"` - InsertedAt string `json:"inserted_at"` - JwksUrl nullable.Nullable[string] `json:"jwks_url,omitempty"` - OidcIssuerUrl nullable.Nullable[string] `json:"oidc_issuer_url,omitempty"` - ResolvedAt nullable.Nullable[string] `json:"resolved_at,omitempty"` - ResolvedJwks nullable.Nullable[interface{}] `json:"resolved_jwks,omitempty"` - Type string `json:"type"` - UpdatedAt string `json:"updated_at"` -} - -// TypescriptResponseOutput defines model for TypescriptResponse_Output. -type TypescriptResponseOutput struct { - Types string `json:"types"` -} - -// UpdateApiKeyBody defines model for UpdateApiKeyBody. -type UpdateApiKeyBody struct { - Description nullable.Nullable[string] `json:"description,omitempty"` - Name *string `json:"name,omitempty"` - SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` -} - -// UpdateAuthConfigBody defines model for UpdateAuthConfigBody. -type UpdateAuthConfigBody struct { - ApiMaxRequestDuration nullable.Nullable[int] `json:"api_max_request_duration,omitempty"` - CustomOauthEnabled *bool `json:"custom_oauth_enabled,omitempty"` - DbMaxPoolSize nullable.Nullable[int] `json:"db_max_pool_size,omitempty"` - DbMaxPoolSizeUnit nullable.Nullable[UpdateAuthConfigBodyDbMaxPoolSizeUnit] `json:"db_max_pool_size_unit,omitempty"` - DisableSignup nullable.Nullable[bool] `json:"disable_signup,omitempty"` - ExternalAnonymousUsersEnabled nullable.Nullable[bool] `json:"external_anonymous_users_enabled,omitempty"` - ExternalAppleAdditionalClientIds nullable.Nullable[string] `json:"external_apple_additional_client_ids,omitempty"` - ExternalAppleClientId nullable.Nullable[string] `json:"external_apple_client_id,omitempty"` - ExternalAppleEmailOptional nullable.Nullable[bool] `json:"external_apple_email_optional,omitempty"` - ExternalAppleEnabled nullable.Nullable[bool] `json:"external_apple_enabled,omitempty"` - ExternalAppleSecret nullable.Nullable[string] `json:"external_apple_secret,omitempty"` - ExternalAzureClientId nullable.Nullable[string] `json:"external_azure_client_id,omitempty"` - ExternalAzureEmailOptional nullable.Nullable[bool] `json:"external_azure_email_optional,omitempty"` - ExternalAzureEnabled nullable.Nullable[bool] `json:"external_azure_enabled,omitempty"` - ExternalAzureSecret nullable.Nullable[string] `json:"external_azure_secret,omitempty"` - ExternalAzureUrl nullable.Nullable[string] `json:"external_azure_url,omitempty"` - ExternalBitbucketClientId nullable.Nullable[string] `json:"external_bitbucket_client_id,omitempty"` - ExternalBitbucketEmailOptional nullable.Nullable[bool] `json:"external_bitbucket_email_optional,omitempty"` - ExternalBitbucketEnabled nullable.Nullable[bool] `json:"external_bitbucket_enabled,omitempty"` - ExternalBitbucketSecret nullable.Nullable[string] `json:"external_bitbucket_secret,omitempty"` - ExternalDiscordClientId nullable.Nullable[string] `json:"external_discord_client_id,omitempty"` - ExternalDiscordEmailOptional nullable.Nullable[bool] `json:"external_discord_email_optional,omitempty"` - ExternalDiscordEnabled nullable.Nullable[bool] `json:"external_discord_enabled,omitempty"` - ExternalDiscordSecret nullable.Nullable[string] `json:"external_discord_secret,omitempty"` - ExternalEmailEnabled nullable.Nullable[bool] `json:"external_email_enabled,omitempty"` - ExternalFacebookClientId nullable.Nullable[string] `json:"external_facebook_client_id,omitempty"` - ExternalFacebookEmailOptional nullable.Nullable[bool] `json:"external_facebook_email_optional,omitempty"` - ExternalFacebookEnabled nullable.Nullable[bool] `json:"external_facebook_enabled,omitempty"` - ExternalFacebookSecret nullable.Nullable[string] `json:"external_facebook_secret,omitempty"` - ExternalFigmaClientId nullable.Nullable[string] `json:"external_figma_client_id,omitempty"` - ExternalFigmaEmailOptional nullable.Nullable[bool] `json:"external_figma_email_optional,omitempty"` - ExternalFigmaEnabled nullable.Nullable[bool] `json:"external_figma_enabled,omitempty"` - ExternalFigmaSecret nullable.Nullable[string] `json:"external_figma_secret,omitempty"` - ExternalGithubClientId nullable.Nullable[string] `json:"external_github_client_id,omitempty"` - ExternalGithubEmailOptional nullable.Nullable[bool] `json:"external_github_email_optional,omitempty"` - ExternalGithubEnabled nullable.Nullable[bool] `json:"external_github_enabled,omitempty"` - ExternalGithubSecret nullable.Nullable[string] `json:"external_github_secret,omitempty"` - ExternalGitlabClientId nullable.Nullable[string] `json:"external_gitlab_client_id,omitempty"` - ExternalGitlabEmailOptional nullable.Nullable[bool] `json:"external_gitlab_email_optional,omitempty"` - ExternalGitlabEnabled nullable.Nullable[bool] `json:"external_gitlab_enabled,omitempty"` - ExternalGitlabSecret nullable.Nullable[string] `json:"external_gitlab_secret,omitempty"` - ExternalGitlabUrl nullable.Nullable[string] `json:"external_gitlab_url,omitempty"` - ExternalGoogleAdditionalClientIds nullable.Nullable[string] `json:"external_google_additional_client_ids,omitempty"` - ExternalGoogleClientId nullable.Nullable[string] `json:"external_google_client_id,omitempty"` - ExternalGoogleEmailOptional nullable.Nullable[bool] `json:"external_google_email_optional,omitempty"` - ExternalGoogleEnabled nullable.Nullable[bool] `json:"external_google_enabled,omitempty"` - ExternalGoogleSecret nullable.Nullable[string] `json:"external_google_secret,omitempty"` - ExternalGoogleSkipNonceCheck nullable.Nullable[bool] `json:"external_google_skip_nonce_check,omitempty"` - ExternalKakaoClientId nullable.Nullable[string] `json:"external_kakao_client_id,omitempty"` - ExternalKakaoEmailOptional nullable.Nullable[bool] `json:"external_kakao_email_optional,omitempty"` - ExternalKakaoEnabled nullable.Nullable[bool] `json:"external_kakao_enabled,omitempty"` - ExternalKakaoSecret nullable.Nullable[string] `json:"external_kakao_secret,omitempty"` - ExternalKeycloakClientId nullable.Nullable[string] `json:"external_keycloak_client_id,omitempty"` - ExternalKeycloakEmailOptional nullable.Nullable[bool] `json:"external_keycloak_email_optional,omitempty"` - ExternalKeycloakEnabled nullable.Nullable[bool] `json:"external_keycloak_enabled,omitempty"` - ExternalKeycloakSecret nullable.Nullable[string] `json:"external_keycloak_secret,omitempty"` - ExternalKeycloakUrl nullable.Nullable[string] `json:"external_keycloak_url,omitempty"` - ExternalLinkedinOidcClientId nullable.Nullable[string] `json:"external_linkedin_oidc_client_id,omitempty"` - ExternalLinkedinOidcEmailOptional nullable.Nullable[bool] `json:"external_linkedin_oidc_email_optional,omitempty"` - ExternalLinkedinOidcEnabled nullable.Nullable[bool] `json:"external_linkedin_oidc_enabled,omitempty"` - ExternalLinkedinOidcSecret nullable.Nullable[string] `json:"external_linkedin_oidc_secret,omitempty"` - ExternalNotionClientId nullable.Nullable[string] `json:"external_notion_client_id,omitempty"` - ExternalNotionEmailOptional nullable.Nullable[bool] `json:"external_notion_email_optional,omitempty"` - ExternalNotionEnabled nullable.Nullable[bool] `json:"external_notion_enabled,omitempty"` - ExternalNotionSecret nullable.Nullable[string] `json:"external_notion_secret,omitempty"` - ExternalPhoneEnabled nullable.Nullable[bool] `json:"external_phone_enabled,omitempty"` - ExternalSlackClientId nullable.Nullable[string] `json:"external_slack_client_id,omitempty"` - ExternalSlackEmailOptional nullable.Nullable[bool] `json:"external_slack_email_optional,omitempty"` - ExternalSlackEnabled nullable.Nullable[bool] `json:"external_slack_enabled,omitempty"` - ExternalSlackOidcClientId nullable.Nullable[string] `json:"external_slack_oidc_client_id,omitempty"` - ExternalSlackOidcEmailOptional nullable.Nullable[bool] `json:"external_slack_oidc_email_optional,omitempty"` - ExternalSlackOidcEnabled nullable.Nullable[bool] `json:"external_slack_oidc_enabled,omitempty"` - ExternalSlackOidcSecret nullable.Nullable[string] `json:"external_slack_oidc_secret,omitempty"` - ExternalSlackSecret nullable.Nullable[string] `json:"external_slack_secret,omitempty"` - ExternalSpotifyClientId nullable.Nullable[string] `json:"external_spotify_client_id,omitempty"` - ExternalSpotifyEmailOptional nullable.Nullable[bool] `json:"external_spotify_email_optional,omitempty"` - ExternalSpotifyEnabled nullable.Nullable[bool] `json:"external_spotify_enabled,omitempty"` - ExternalSpotifySecret nullable.Nullable[string] `json:"external_spotify_secret,omitempty"` - ExternalTwitchClientId nullable.Nullable[string] `json:"external_twitch_client_id,omitempty"` - ExternalTwitchEmailOptional nullable.Nullable[bool] `json:"external_twitch_email_optional,omitempty"` - ExternalTwitchEnabled nullable.Nullable[bool] `json:"external_twitch_enabled,omitempty"` - ExternalTwitchSecret nullable.Nullable[string] `json:"external_twitch_secret,omitempty"` - ExternalTwitterClientId nullable.Nullable[string] `json:"external_twitter_client_id,omitempty"` - ExternalTwitterEmailOptional nullable.Nullable[bool] `json:"external_twitter_email_optional,omitempty"` - ExternalTwitterEnabled nullable.Nullable[bool] `json:"external_twitter_enabled,omitempty"` - ExternalTwitterSecret nullable.Nullable[string] `json:"external_twitter_secret,omitempty"` - ExternalWeb3EthereumEnabled nullable.Nullable[bool] `json:"external_web3_ethereum_enabled,omitempty"` - ExternalWeb3SolanaEnabled nullable.Nullable[bool] `json:"external_web3_solana_enabled,omitempty"` - ExternalWorkosClientId nullable.Nullable[string] `json:"external_workos_client_id,omitempty"` - ExternalWorkosEnabled nullable.Nullable[bool] `json:"external_workos_enabled,omitempty"` - ExternalWorkosSecret nullable.Nullable[string] `json:"external_workos_secret,omitempty"` - ExternalWorkosUrl nullable.Nullable[string] `json:"external_workos_url,omitempty"` - ExternalXClientId nullable.Nullable[string] `json:"external_x_client_id,omitempty"` - ExternalXEmailOptional nullable.Nullable[bool] `json:"external_x_email_optional,omitempty"` - ExternalXEnabled nullable.Nullable[bool] `json:"external_x_enabled,omitempty"` - ExternalXSecret nullable.Nullable[string] `json:"external_x_secret,omitempty"` - ExternalZoomClientId nullable.Nullable[string] `json:"external_zoom_client_id,omitempty"` - ExternalZoomEmailOptional nullable.Nullable[bool] `json:"external_zoom_email_optional,omitempty"` - ExternalZoomEnabled nullable.Nullable[bool] `json:"external_zoom_enabled,omitempty"` - ExternalZoomSecret nullable.Nullable[string] `json:"external_zoom_secret,omitempty"` - HookAfterUserCreatedEnabled nullable.Nullable[bool] `json:"hook_after_user_created_enabled,omitempty"` - HookAfterUserCreatedSecrets nullable.Nullable[string] `json:"hook_after_user_created_secrets,omitempty"` - HookAfterUserCreatedUri nullable.Nullable[string] `json:"hook_after_user_created_uri,omitempty"` - HookBeforeUserCreatedEnabled nullable.Nullable[bool] `json:"hook_before_user_created_enabled,omitempty"` - HookBeforeUserCreatedSecrets nullable.Nullable[string] `json:"hook_before_user_created_secrets,omitempty"` - HookBeforeUserCreatedUri nullable.Nullable[string] `json:"hook_before_user_created_uri,omitempty"` - HookCustomAccessTokenEnabled nullable.Nullable[bool] `json:"hook_custom_access_token_enabled,omitempty"` - HookCustomAccessTokenSecrets nullable.Nullable[string] `json:"hook_custom_access_token_secrets,omitempty"` - HookCustomAccessTokenUri nullable.Nullable[string] `json:"hook_custom_access_token_uri,omitempty"` - HookMfaVerificationAttemptEnabled nullable.Nullable[bool] `json:"hook_mfa_verification_attempt_enabled,omitempty"` - HookMfaVerificationAttemptSecrets nullable.Nullable[string] `json:"hook_mfa_verification_attempt_secrets,omitempty"` - HookMfaVerificationAttemptUri nullable.Nullable[string] `json:"hook_mfa_verification_attempt_uri,omitempty"` - HookPasswordVerificationAttemptEnabled nullable.Nullable[bool] `json:"hook_password_verification_attempt_enabled,omitempty"` - HookPasswordVerificationAttemptSecrets nullable.Nullable[string] `json:"hook_password_verification_attempt_secrets,omitempty"` - HookPasswordVerificationAttemptUri nullable.Nullable[string] `json:"hook_password_verification_attempt_uri,omitempty"` - HookSendEmailEnabled nullable.Nullable[bool] `json:"hook_send_email_enabled,omitempty"` - HookSendEmailSecrets nullable.Nullable[string] `json:"hook_send_email_secrets,omitempty"` - HookSendEmailUri nullable.Nullable[string] `json:"hook_send_email_uri,omitempty"` - HookSendSmsEnabled nullable.Nullable[bool] `json:"hook_send_sms_enabled,omitempty"` - HookSendSmsSecrets nullable.Nullable[string] `json:"hook_send_sms_secrets,omitempty"` - HookSendSmsUri nullable.Nullable[string] `json:"hook_send_sms_uri,omitempty"` - JwtExp nullable.Nullable[int] `json:"jwt_exp,omitempty"` - MailerAllowUnverifiedEmailSignIns nullable.Nullable[bool] `json:"mailer_allow_unverified_email_sign_ins,omitempty"` - MailerAutoconfirm nullable.Nullable[bool] `json:"mailer_autoconfirm,omitempty"` - MailerNotificationsEmailChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_email_changed_enabled,omitempty"` - MailerNotificationsIdentityLinkedEnabled nullable.Nullable[bool] `json:"mailer_notifications_identity_linked_enabled,omitempty"` - MailerNotificationsIdentityUnlinkedEnabled nullable.Nullable[bool] `json:"mailer_notifications_identity_unlinked_enabled,omitempty"` - MailerNotificationsMfaFactorEnrolledEnabled nullable.Nullable[bool] `json:"mailer_notifications_mfa_factor_enrolled_enabled,omitempty"` - MailerNotificationsMfaFactorUnenrolledEnabled nullable.Nullable[bool] `json:"mailer_notifications_mfa_factor_unenrolled_enabled,omitempty"` - MailerNotificationsPasswordChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_password_changed_enabled,omitempty"` - MailerNotificationsPhoneChangedEnabled nullable.Nullable[bool] `json:"mailer_notifications_phone_changed_enabled,omitempty"` - MailerOtpExp *int `json:"mailer_otp_exp,omitempty"` - MailerOtpLength nullable.Nullable[int] `json:"mailer_otp_length,omitempty"` - MailerSecureEmailChangeEnabled nullable.Nullable[bool] `json:"mailer_secure_email_change_enabled,omitempty"` - MailerSubjectsConfirmation nullable.Nullable[string] `json:"mailer_subjects_confirmation,omitempty"` - MailerSubjectsEmailChange nullable.Nullable[string] `json:"mailer_subjects_email_change,omitempty"` - MailerSubjectsEmailChangedNotification nullable.Nullable[string] `json:"mailer_subjects_email_changed_notification,omitempty"` - MailerSubjectsIdentityLinkedNotification nullable.Nullable[string] `json:"mailer_subjects_identity_linked_notification,omitempty"` - MailerSubjectsIdentityUnlinkedNotification nullable.Nullable[string] `json:"mailer_subjects_identity_unlinked_notification,omitempty"` - MailerSubjectsInvite nullable.Nullable[string] `json:"mailer_subjects_invite,omitempty"` - MailerSubjectsMagicLink nullable.Nullable[string] `json:"mailer_subjects_magic_link,omitempty"` - MailerSubjectsMfaFactorEnrolledNotification nullable.Nullable[string] `json:"mailer_subjects_mfa_factor_enrolled_notification,omitempty"` - MailerSubjectsMfaFactorUnenrolledNotification nullable.Nullable[string] `json:"mailer_subjects_mfa_factor_unenrolled_notification,omitempty"` - MailerSubjectsPasswordChangedNotification nullable.Nullable[string] `json:"mailer_subjects_password_changed_notification,omitempty"` - MailerSubjectsPhoneChangedNotification nullable.Nullable[string] `json:"mailer_subjects_phone_changed_notification,omitempty"` - MailerSubjectsReauthentication nullable.Nullable[string] `json:"mailer_subjects_reauthentication,omitempty"` - MailerSubjectsRecovery nullable.Nullable[string] `json:"mailer_subjects_recovery,omitempty"` - MailerTemplatesConfirmationContent nullable.Nullable[string] `json:"mailer_templates_confirmation_content,omitempty"` - MailerTemplatesEmailChangeContent nullable.Nullable[string] `json:"mailer_templates_email_change_content,omitempty"` - MailerTemplatesEmailChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_email_changed_notification_content,omitempty"` - MailerTemplatesIdentityLinkedNotificationContent nullable.Nullable[string] `json:"mailer_templates_identity_linked_notification_content,omitempty"` - MailerTemplatesIdentityUnlinkedNotificationContent nullable.Nullable[string] `json:"mailer_templates_identity_unlinked_notification_content,omitempty"` - MailerTemplatesInviteContent nullable.Nullable[string] `json:"mailer_templates_invite_content,omitempty"` - MailerTemplatesMagicLinkContent nullable.Nullable[string] `json:"mailer_templates_magic_link_content,omitempty"` - MailerTemplatesMfaFactorEnrolledNotificationContent nullable.Nullable[string] `json:"mailer_templates_mfa_factor_enrolled_notification_content,omitempty"` - MailerTemplatesMfaFactorUnenrolledNotificationContent nullable.Nullable[string] `json:"mailer_templates_mfa_factor_unenrolled_notification_content,omitempty"` - MailerTemplatesPasswordChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_password_changed_notification_content,omitempty"` - MailerTemplatesPhoneChangedNotificationContent nullable.Nullable[string] `json:"mailer_templates_phone_changed_notification_content,omitempty"` - MailerTemplatesReauthenticationContent nullable.Nullable[string] `json:"mailer_templates_reauthentication_content,omitempty"` - MailerTemplatesRecoveryContent nullable.Nullable[string] `json:"mailer_templates_recovery_content,omitempty"` - MfaMaxEnrolledFactors nullable.Nullable[int] `json:"mfa_max_enrolled_factors,omitempty"` - MfaPhoneEnrollEnabled nullable.Nullable[bool] `json:"mfa_phone_enroll_enabled,omitempty"` - MfaPhoneMaxFrequency nullable.Nullable[int] `json:"mfa_phone_max_frequency,omitempty"` - MfaPhoneOtpLength nullable.Nullable[int] `json:"mfa_phone_otp_length,omitempty"` - MfaPhoneTemplate nullable.Nullable[string] `json:"mfa_phone_template,omitempty"` - MfaPhoneVerifyEnabled nullable.Nullable[bool] `json:"mfa_phone_verify_enabled,omitempty"` - MfaTotpEnrollEnabled nullable.Nullable[bool] `json:"mfa_totp_enroll_enabled,omitempty"` - MfaTotpVerifyEnabled nullable.Nullable[bool] `json:"mfa_totp_verify_enabled,omitempty"` - MfaWebAuthnEnrollEnabled nullable.Nullable[bool] `json:"mfa_web_authn_enroll_enabled,omitempty"` - MfaWebAuthnVerifyEnabled nullable.Nullable[bool] `json:"mfa_web_authn_verify_enabled,omitempty"` - NimbusOauthClientId nullable.Nullable[string] `json:"nimbus_oauth_client_id,omitempty"` - NimbusOauthClientSecret nullable.Nullable[string] `json:"nimbus_oauth_client_secret,omitempty"` - OauthServerAllowDynamicRegistration nullable.Nullable[bool] `json:"oauth_server_allow_dynamic_registration,omitempty"` - OauthServerAuthorizationPath nullable.Nullable[string] `json:"oauth_server_authorization_path,omitempty"` - OauthServerEnabled nullable.Nullable[bool] `json:"oauth_server_enabled,omitempty"` - PasskeyEnabled *bool `json:"passkey_enabled,omitempty"` - PasswordHibpEnabled nullable.Nullable[bool] `json:"password_hibp_enabled,omitempty"` - PasswordMinLength nullable.Nullable[int] `json:"password_min_length,omitempty"` - PasswordRequiredCharacters nullable.Nullable[UpdateAuthConfigBodyPasswordRequiredCharacters] `json:"password_required_characters,omitempty"` - RateLimitAnonymousUsers nullable.Nullable[int] `json:"rate_limit_anonymous_users,omitempty"` - RateLimitEmailSent nullable.Nullable[int] `json:"rate_limit_email_sent,omitempty"` - RateLimitOtp nullable.Nullable[int] `json:"rate_limit_otp,omitempty"` - RateLimitSmsSent nullable.Nullable[int] `json:"rate_limit_sms_sent,omitempty"` - RateLimitTokenRefresh nullable.Nullable[int] `json:"rate_limit_token_refresh,omitempty"` - RateLimitVerify nullable.Nullable[int] `json:"rate_limit_verify,omitempty"` - RateLimitWeb3 nullable.Nullable[int] `json:"rate_limit_web3,omitempty"` - RefreshTokenRotationEnabled nullable.Nullable[bool] `json:"refresh_token_rotation_enabled,omitempty"` - SamlEnabled nullable.Nullable[bool] `json:"saml_enabled,omitempty"` - SamlExternalUrl nullable.Nullable[string] `json:"saml_external_url,omitempty"` - SecurityCaptchaEnabled nullable.Nullable[bool] `json:"security_captcha_enabled,omitempty"` - SecurityCaptchaProvider nullable.Nullable[UpdateAuthConfigBodySecurityCaptchaProvider] `json:"security_captcha_provider,omitempty"` - SecurityCaptchaSecret nullable.Nullable[string] `json:"security_captcha_secret,omitempty"` - SecurityManualLinkingEnabled nullable.Nullable[bool] `json:"security_manual_linking_enabled,omitempty"` - - // SecurityRefreshTokenReuseInterval Refresh token reuse interval in seconds. Maximum 300 seconds (5 minutes). - SecurityRefreshTokenReuseInterval nullable.Nullable[int] `json:"security_refresh_token_reuse_interval,omitempty"` - SecuritySbForwardedForEnabled nullable.Nullable[bool] `json:"security_sb_forwarded_for_enabled,omitempty"` - - // SecurityUpdatePasswordRequireCurrentPassword Require the user's current password when updating their password. - SecurityUpdatePasswordRequireCurrentPassword nullable.Nullable[bool] `json:"security_update_password_require_current_password,omitempty"` - SecurityUpdatePasswordRequireReauthentication nullable.Nullable[bool] `json:"security_update_password_require_reauthentication,omitempty"` - - // SessionsInactivityTimeout Session inactivity timeout in hours. Maximum 8760 hours (1 year). - SessionsInactivityTimeout nullable.Nullable[float32] `json:"sessions_inactivity_timeout,omitempty"` - SessionsSinglePerUser nullable.Nullable[bool] `json:"sessions_single_per_user,omitempty"` - SessionsTags nullable.Nullable[string] `json:"sessions_tags,omitempty"` - - // SessionsTimebox Session timebox in hours. Maximum 8760 hours (1 year). - SessionsTimebox nullable.Nullable[float32] `json:"sessions_timebox,omitempty"` - SiteUrl nullable.Nullable[string] `json:"site_url,omitempty"` - SmsAutoconfirm nullable.Nullable[bool] `json:"sms_autoconfirm,omitempty"` - SmsMaxFrequency nullable.Nullable[int] `json:"sms_max_frequency,omitempty"` - SmsMessagebirdAccessKey nullable.Nullable[string] `json:"sms_messagebird_access_key,omitempty"` - SmsMessagebirdOriginator nullable.Nullable[string] `json:"sms_messagebird_originator,omitempty"` - SmsOtpExp nullable.Nullable[int] `json:"sms_otp_exp,omitempty"` - SmsOtpLength *int `json:"sms_otp_length,omitempty"` - SmsProvider nullable.Nullable[UpdateAuthConfigBodySmsProvider] `json:"sms_provider,omitempty"` - SmsTemplate nullable.Nullable[string] `json:"sms_template,omitempty"` - SmsTestOtp nullable.Nullable[string] `json:"sms_test_otp,omitempty"` - SmsTestOtpValidUntil nullable.Nullable[time.Time] `json:"sms_test_otp_valid_until,omitempty"` - SmsTextlocalApiKey nullable.Nullable[string] `json:"sms_textlocal_api_key,omitempty"` - SmsTextlocalSender nullable.Nullable[string] `json:"sms_textlocal_sender,omitempty"` - SmsTwilioAccountSid nullable.Nullable[string] `json:"sms_twilio_account_sid,omitempty"` - SmsTwilioAuthToken nullable.Nullable[string] `json:"sms_twilio_auth_token,omitempty"` - SmsTwilioContentSid nullable.Nullable[string] `json:"sms_twilio_content_sid,omitempty"` - SmsTwilioMessageServiceSid nullable.Nullable[string] `json:"sms_twilio_message_service_sid,omitempty"` - SmsTwilioVerifyAccountSid nullable.Nullable[string] `json:"sms_twilio_verify_account_sid,omitempty"` - SmsTwilioVerifyAuthToken nullable.Nullable[string] `json:"sms_twilio_verify_auth_token,omitempty"` - SmsTwilioVerifyMessageServiceSid nullable.Nullable[string] `json:"sms_twilio_verify_message_service_sid,omitempty"` - SmsVonageApiKey nullable.Nullable[string] `json:"sms_vonage_api_key,omitempty"` - SmsVonageApiSecret nullable.Nullable[string] `json:"sms_vonage_api_secret,omitempty"` - SmsVonageFrom nullable.Nullable[string] `json:"sms_vonage_from,omitempty"` - SmtpAdminEmail nullable.Nullable[openapi_types.Email] `json:"smtp_admin_email,omitempty"` - SmtpHost nullable.Nullable[string] `json:"smtp_host,omitempty"` - SmtpMaxFrequency nullable.Nullable[int] `json:"smtp_max_frequency,omitempty"` - SmtpPass nullable.Nullable[string] `json:"smtp_pass,omitempty"` - SmtpPort nullable.Nullable[string] `json:"smtp_port,omitempty"` - SmtpSenderName nullable.Nullable[string] `json:"smtp_sender_name,omitempty"` - SmtpUser nullable.Nullable[string] `json:"smtp_user,omitempty"` - UriAllowList nullable.Nullable[string] `json:"uri_allow_list,omitempty"` - WebauthnRpDisplayName nullable.Nullable[string] `json:"webauthn_rp_display_name,omitempty"` - WebauthnRpId nullable.Nullable[string] `json:"webauthn_rp_id,omitempty"` - WebauthnRpOrigins nullable.Nullable[string] `json:"webauthn_rp_origins,omitempty"` -} - -// UpdateAuthConfigBodyDbMaxPoolSizeUnit defines model for UpdateAuthConfigBody.DbMaxPoolSizeUnit. -type UpdateAuthConfigBodyDbMaxPoolSizeUnit string - -// UpdateAuthConfigBodyPasswordRequiredCharacters defines model for UpdateAuthConfigBody.PasswordRequiredCharacters. -type UpdateAuthConfigBodyPasswordRequiredCharacters string - -// UpdateAuthConfigBodySecurityCaptchaProvider defines model for UpdateAuthConfigBody.SecurityCaptchaProvider. -type UpdateAuthConfigBodySecurityCaptchaProvider string - -// UpdateAuthConfigBodySmsProvider defines model for UpdateAuthConfigBody.SmsProvider. -type UpdateAuthConfigBodySmsProvider string - -// UpdateBranchBody defines model for UpdateBranchBody. -type UpdateBranchBody struct { - BranchName *string `json:"branch_name,omitempty"` - GitBranch *string `json:"git_branch,omitempty"` - - // NotifyUrl HTTP endpoint to receive branch status updates. - NotifyUrl *string `json:"notify_url,omitempty"` - Persistent *bool `json:"persistent,omitempty"` - RequestReview *bool `json:"request_review,omitempty"` - - // ResetOnPush This field is deprecated and will be ignored. Use v1-reset-a-branch endpoint directly instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - ResetOnPush *bool `json:"reset_on_push,omitempty"` - Status *UpdateBranchBodyStatus `json:"status,omitempty"` -} - -// UpdateBranchBodyStatus defines model for UpdateBranchBody.Status. -type UpdateBranchBodyStatus string - -// UpdateCustomHostnameBody defines model for UpdateCustomHostnameBody. -type UpdateCustomHostnameBody struct { - CustomHostname string `json:"custom_hostname"` -} - -// UpdateCustomHostnameResponseOutput defines model for UpdateCustomHostnameResponse_Output. -type UpdateCustomHostnameResponseOutput struct { - CustomHostname *string `json:"custom_hostname,omitempty"` - Data struct { - Errors []JsonValueOutput `json:"errors"` - Messages []JsonValueOutput `json:"messages"` - Result struct { - CustomOriginServer *string `json:"custom_origin_server,omitempty"` - Hostname string `json:"hostname"` - Id string `json:"id"` - OwnershipVerification *struct { - Name *string `json:"name,omitempty"` - Type *string `json:"type,omitempty"` - Value *string `json:"value,omitempty"` - } `json:"ownership_verification,omitempty"` - Ssl *struct { - Status *string `json:"status,omitempty"` - ValidationErrors *[]struct { - Message string `json:"message"` - } `json:"validation_errors,omitempty"` - ValidationRecords *[]struct { - TxtName *string `json:"txt_name,omitempty"` - TxtValue *string `json:"txt_value,omitempty"` - } `json:"validation_records,omitempty"` - } `json:"ssl,omitempty"` - Status *string `json:"status,omitempty"` - VerificationErrors *[]string `json:"verification_errors,omitempty"` - } `json:"result"` - Success bool `json:"success"` - } `json:"data"` - Status UpdateCustomHostnameResponseOutputStatus `json:"status"` -} - -// UpdateCustomHostnameResponseOutputStatus defines model for UpdateCustomHostnameResponseOutput.Status. -type UpdateCustomHostnameResponseOutputStatus string - -// UpdateJitAccessBody defines model for UpdateJitAccessBody. -type UpdateJitAccessBody struct { - Roles []struct { - AllowedNetworks *struct { - AllowedCidrs *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs,omitempty"` - AllowedCidrsV6 *[]struct { - Cidr string `json:"cidr"` - } `json:"allowed_cidrs_v6,omitempty"` - } `json:"allowed_networks,omitempty"` - BranchesOnly *bool `json:"branches_only,omitempty"` - ExpiresAt *float32 `json:"expires_at,omitempty"` - Role string `json:"role"` - } `json:"roles"` - UserId openapi_types.UUID `json:"user_id"` -} - -// UpdatePgsodiumConfigBody defines model for UpdatePgsodiumConfigBody. -type UpdatePgsodiumConfigBody struct { - // RootKey The pgsodium root key: 32 bytes, hex-encoded (64 characters). - RootKey string `json:"root_key"` -} - -// UpdatePostgresConfigBody defines model for UpdatePostgresConfigBody. -type UpdatePostgresConfigBody struct { - AutovacuumMaxWorkers *int `json:"autovacuum_max_workers,omitempty"` - AutovacuumWorkMem *string `json:"autovacuum_work_mem,omitempty"` - - // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - CronLogStatement *bool `json:"cron.log_statement,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` - - // LogAutovacuumMinDuration Default unit: ms - LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` - LogCheckpoints *bool `json:"log_checkpoints,omitempty"` - LogConnections *bool `json:"log_connections,omitempty"` - LogDisconnections *bool `json:"log_disconnections,omitempty"` - LogDuration *bool `json:"log_duration,omitempty"` - LogLockWaits *bool `json:"log_lock_waits,omitempty"` - LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` - LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` - - // LogStartupProgressInterval Default unit: ms - LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` - LogTempFiles *string `json:"log_temp_files,omitempty"` - LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` - MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` - MaxConnections *int `json:"max_connections,omitempty"` - MaxLocksPerTransaction *int `json:"max_locks_per_transaction,omitempty"` - MaxLogicalReplicationWorkers *int `json:"max_logical_replication_workers,omitempty"` - MaxParallelMaintenanceWorkers *int `json:"max_parallel_maintenance_workers,omitempty"` - MaxParallelWorkers *int `json:"max_parallel_workers,omitempty"` - MaxParallelWorkersPerGather *int `json:"max_parallel_workers_per_gather,omitempty"` - MaxReplicationSlots *int `json:"max_replication_slots,omitempty"` - MaxSlotWalKeepSize *string `json:"max_slot_wal_keep_size,omitempty"` - MaxStandbyArchiveDelay *string `json:"max_standby_archive_delay,omitempty"` - MaxStandbyStreamingDelay *string `json:"max_standby_streaming_delay,omitempty"` - MaxSyncWorkersPerSubscription *int `json:"max_sync_workers_per_subscription,omitempty"` - MaxWalSenders *int `json:"max_wal_senders,omitempty"` - MaxWalSize *string `json:"max_wal_size,omitempty"` - MaxWorkerProcesses *int `json:"max_worker_processes,omitempty"` - PgNetBatchSize *int `json:"pg_net.batch_size,omitempty"` - PgStatStatementsMax *int `json:"pg_stat_statements.max,omitempty"` - RestartDatabase *bool `json:"restart_database,omitempty"` - SessionReplicationRole *UpdatePostgresConfigBodySessionReplicationRole `json:"session_replication_role,omitempty"` - SharedBuffers *string `json:"shared_buffers,omitempty"` - - // StatementTimeout Default unit: ms - StatementTimeout *string `json:"statement_timeout,omitempty"` - TrackActivityQuerySize *string `json:"track_activity_query_size,omitempty"` - TrackCommitTimestamp *bool `json:"track_commit_timestamp,omitempty"` - WalKeepSize *string `json:"wal_keep_size,omitempty"` - - // WalSenderTimeout Default unit: ms - WalSenderTimeout *string `json:"wal_sender_timeout,omitempty"` - WorkMem *string `json:"work_mem,omitempty"` -} - -// UpdatePostgresConfigBodySessionReplicationRole defines model for UpdatePostgresConfigBody.SessionReplicationRole. -type UpdatePostgresConfigBodySessionReplicationRole string - -// UpdateProviderBody defines model for UpdateProviderBody. -type UpdateProviderBody struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - Domains *[]string `json:"domains,omitempty"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *UpdateProviderBodyNameIdFormat `json:"name_id_format,omitempty"` -} - -// UpdateProviderBodyNameIdFormat defines model for UpdateProviderBody.NameIdFormat. -type UpdateProviderBodyNameIdFormat string - -// UpdateProviderResponseOutput defines model for UpdateProviderResponse_Output. -type UpdateProviderResponseOutput struct { - CreatedAt *string `json:"created_at,omitempty"` - Domains *[]struct { - CreatedAt *string `json:"created_at,omitempty"` - Domain *string `json:"domain,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - } `json:"domains,omitempty"` - Id string `json:"id"` - Saml *struct { - AttributeMapping *struct { - Keys map[string]struct { - Array *bool `json:"array,omitempty"` - Default interface{} `json:"default,omitempty"` - Name *string `json:"name,omitempty"` - Names *[]string `json:"names,omitempty"` - } `json:"keys"` - } `json:"attribute_mapping,omitempty"` - EntityId string `json:"entity_id"` - MetadataUrl *string `json:"metadata_url,omitempty"` - MetadataXml *string `json:"metadata_xml,omitempty"` - NameIdFormat *string `json:"name_id_format,omitempty"` - } `json:"saml,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` -} - -// UpdateRealtimeConfigBody defines model for UpdateRealtimeConfigBody. -type UpdateRealtimeConfigBody struct { - // ConnectionPool Sets connection pool size for Realtime Authorization - ConnectionPool *int `json:"connection_pool,omitempty"` - - // MaxBytesPerSecond Sets maximum number of bytes per second rate per channel limit - MaxBytesPerSecond *int `json:"max_bytes_per_second,omitempty"` - - // MaxChannelsPerClient Sets maximum number of channels per client rate limit - MaxChannelsPerClient *int `json:"max_channels_per_client,omitempty"` - - // MaxConcurrentUsers Sets maximum number of concurrent users rate limit - MaxConcurrentUsers *int `json:"max_concurrent_users,omitempty"` - - // MaxEventsPerSecond Sets maximum number of events per second rate per channel limit - MaxEventsPerSecond *int `json:"max_events_per_second,omitempty"` - - // MaxJoinsPerSecond Sets maximum number of joins per second rate limit - MaxJoinsPerSecond *int `json:"max_joins_per_second,omitempty"` - - // MaxPayloadSizeInKb Sets maximum number of payload size in KB rate limit - MaxPayloadSizeInKb *int `json:"max_payload_size_in_kb,omitempty"` - - // MaxPresenceEventsPerSecond Sets maximum number of presence events per second rate limit - MaxPresenceEventsPerSecond *int `json:"max_presence_events_per_second,omitempty"` - - // PostgresChangesPool Sets connection pool size used to create Postgres Changes subscriptions - PostgresChangesPool *int `json:"postgres_changes_pool,omitempty"` - - // PresenceEnabled Whether to enable presence - PresenceEnabled *bool `json:"presence_enabled,omitempty"` - - // PrivateOnly Whether to only allow private channels - PrivateOnly *bool `json:"private_only,omitempty"` - - // Suspend Disables the Realtime service for this project when true. Set to false to re-enable it. - Suspend *bool `json:"suspend,omitempty"` -} - -// UpdateRunStatusBody defines model for UpdateRunStatusBody. -type UpdateRunStatusBody struct { - Clone *UpdateRunStatusBodyClone `json:"clone,omitempty"` - Configure *UpdateRunStatusBodyConfigure `json:"configure,omitempty"` - Deploy *UpdateRunStatusBodyDeploy `json:"deploy,omitempty"` - Health *UpdateRunStatusBodyHealth `json:"health,omitempty"` - Migrate *UpdateRunStatusBodyMigrate `json:"migrate,omitempty"` - Pull *UpdateRunStatusBodyPull `json:"pull,omitempty"` - Seed *UpdateRunStatusBodySeed `json:"seed,omitempty"` -} - -// UpdateRunStatusBodyClone defines model for UpdateRunStatusBody.Clone. -type UpdateRunStatusBodyClone string - -// UpdateRunStatusBodyConfigure defines model for UpdateRunStatusBody.Configure. -type UpdateRunStatusBodyConfigure string - -// UpdateRunStatusBodyDeploy defines model for UpdateRunStatusBody.Deploy. -type UpdateRunStatusBodyDeploy string - -// UpdateRunStatusBodyHealth defines model for UpdateRunStatusBody.Health. -type UpdateRunStatusBodyHealth string - -// UpdateRunStatusBodyMigrate defines model for UpdateRunStatusBody.Migrate. -type UpdateRunStatusBodyMigrate string - -// UpdateRunStatusBodyPull defines model for UpdateRunStatusBody.Pull. -type UpdateRunStatusBodyPull string - -// UpdateRunStatusBodySeed defines model for UpdateRunStatusBody.Seed. -type UpdateRunStatusBodySeed string - -// UpdateRunStatusResponseOutput defines model for UpdateRunStatusResponse_Output. -type UpdateRunStatusResponseOutput struct { - Message UpdateRunStatusResponseOutputMessage `json:"message"` -} - -// UpdateRunStatusResponseOutputMessage defines model for UpdateRunStatusResponseOutput.Message. -type UpdateRunStatusResponseOutputMessage string - -// UpdateSigningKeyBody defines model for UpdateSigningKeyBody. -type UpdateSigningKeyBody struct { - Status UpdateSigningKeyBodyStatus `json:"status"` -} - -// UpdateSigningKeyBodyStatus defines model for UpdateSigningKeyBody.Status. -type UpdateSigningKeyBodyStatus string - -// UpdateStorageConfigBody defines model for UpdateStorageConfigBody. -type UpdateStorageConfigBody struct { - External *struct { - UpstreamTarget UpdateStorageConfigBodyExternalUpstreamTarget `json:"upstreamTarget"` - } `json:"external,omitempty"` - Features *struct { - IcebergCatalog *struct { - Enabled bool `json:"enabled"` - MaxCatalogs int `json:"maxCatalogs"` - MaxNamespaces int `json:"maxNamespaces"` - MaxTables int `json:"maxTables"` - } `json:"icebergCatalog,omitempty"` - ImageTransformation *struct { - Enabled bool `json:"enabled"` - } `json:"imageTransformation,omitempty"` - PurgeCache *struct { - Enabled bool `json:"enabled"` - } `json:"purgeCache,omitempty"` - S3Protocol *struct { - Enabled bool `json:"enabled"` - } `json:"s3Protocol,omitempty"` - VectorBuckets *struct { - Enabled bool `json:"enabled"` - MaxBuckets int `json:"maxBuckets"` - MaxIndexes int `json:"maxIndexes"` - } `json:"vectorBuckets,omitempty"` - } `json:"features,omitempty"` - FileSizeLimit *int64 `json:"fileSizeLimit,omitempty"` -} - -// UpdateStorageConfigBodyExternalUpstreamTarget defines model for UpdateStorageConfigBody.External.UpstreamTarget. -type UpdateStorageConfigBodyExternalUpstreamTarget string - -// UpdateSupavisorConfigBody defines model for UpdateSupavisorConfigBody. -type UpdateSupavisorConfigBody struct { - DefaultPoolSize nullable.Nullable[int] `json:"default_pool_size,omitempty"` - - // PoolMode Dedicated pooler mode for the project - PoolMode *UpdateSupavisorConfigBodyPoolMode `json:"pool_mode,omitempty"` -} - -// UpdateSupavisorConfigBodyPoolMode Dedicated pooler mode for the project -type UpdateSupavisorConfigBodyPoolMode string - -// UpdateSupavisorConfigResponseOutput defines model for UpdateSupavisorConfigResponse_Output. -type UpdateSupavisorConfigResponseOutput struct { - DefaultPoolSize nullable.Nullable[int] `json:"default_pool_size"` - PoolMode string `json:"pool_mode"` -} - -// UpgradeDatabaseBody defines model for UpgradeDatabaseBody. -type UpgradeDatabaseBody struct { - ReleaseChannel *UpgradeDatabaseBodyReleaseChannel `json:"release_channel,omitempty"` - TargetVersion string `json:"target_version"` -} - -// UpgradeDatabaseBodyReleaseChannel defines model for UpgradeDatabaseBody.ReleaseChannel. -type UpgradeDatabaseBodyReleaseChannel string - -// V1BackupScheduleResponseOutput defines model for V1BackupScheduleResponse_Output. -type V1BackupScheduleResponseOutput struct { - // ScheduleFor Time of day to schedule daily backups, in UTC. Format: HH:MM:SS. - ScheduleFor string `json:"schedule_for"` - - // UpdatedAt Timestamp of when the backup schedule was last updated. - UpdatedAt time.Time `json:"updated_at"` -} - -// V1BackupsResponseOutput defines model for V1BackupsResponse_Output. -type V1BackupsResponseOutput struct { - Backups []struct { - Id int `json:"id"` - InsertedAt string `json:"inserted_at"` - IsPhysicalBackup bool `json:"is_physical_backup"` - Status V1BackupsResponseOutputBackupsStatus `json:"status"` - } `json:"backups"` - PhysicalBackupData struct { - EarliestPhysicalBackupDateUnix *int `json:"earliest_physical_backup_date_unix,omitempty"` - LatestPhysicalBackupDateUnix *int `json:"latest_physical_backup_date_unix,omitempty"` - } `json:"physical_backup_data"` - PitrEnabled bool `json:"pitr_enabled"` - Region string `json:"region"` - WalgEnabled bool `json:"walg_enabled"` -} - -// V1BackupsResponseOutputBackupsStatus defines model for V1BackupsResponseOutput.Backups.Status. -type V1BackupsResponseOutputBackupsStatus string - -// V1CreateFunctionBody defines model for V1CreateFunctionBody. -type V1CreateFunctionBody struct { - Body string `json:"body"` - Name string `json:"name"` - Slug string `json:"slug"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` -} - -// V1CreateMigrationBody defines model for V1CreateMigrationBody. -type V1CreateMigrationBody struct { - Name *string `json:"name,omitempty"` - Query string `json:"query"` - Rollback *string `json:"rollback,omitempty"` -} - -// V1CreateProjectBody defines model for V1CreateProjectBody. -type V1CreateProjectBody struct { - // DbPass Database password - DbPass string `json:"db_pass"` - - // DesiredInstanceSize Desired instance size. Omit this field to always default to the smallest possible size. - DesiredInstanceSize *V1CreateProjectBodyDesiredInstanceSize `json:"desired_instance_size,omitempty"` - - // HighAvailability [Experimental] Whether to enable high availability for the project. - HighAvailability *bool `json:"high_availability,omitempty"` - - // KpsEnabled This field is deprecated and is ignored in this request - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - KpsEnabled *bool `json:"kps_enabled,omitempty"` - - // Name Name of your project - Name string `json:"name"` - - // OrganizationId Deprecated: Use `organization_slug` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - OrganizationId *string `json:"organization_id,omitempty"` - - // OrganizationSlug Organization slug - OrganizationSlug string `json:"organization_slug"` - - // Plan Subscription Plan is now set on organization level and is ignored in this request - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Plan *V1CreateProjectBodyPlan `json:"plan,omitempty"` - - // Region Region you want your server to reside in. Use region_selection instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Region *V1CreateProjectBodyRegion `json:"region,omitempty"` - - // RegionSelection Region selection. Only one of region or region_selection can be specified. - RegionSelection *V1CreateProjectBody_RegionSelection `json:"region_selection,omitempty"` - - // TemplateUrl Template URL used to create the project from the CLI. - TemplateUrl *string `json:"template_url,omitempty"` -} - -// V1CreateProjectBodyDesiredInstanceSize Desired instance size. Omit this field to always default to the smallest possible size. -type V1CreateProjectBodyDesiredInstanceSize string - -// V1CreateProjectBodyPlan Subscription Plan is now set on organization level and is ignored in this request -type V1CreateProjectBodyPlan string - -// V1CreateProjectBodyRegion Region you want your server to reside in. Use region_selection instead. -type V1CreateProjectBodyRegion string - -// V1CreateProjectBodyRegionSelection0 defines model for . -type V1CreateProjectBodyRegionSelection0 struct { - // Code Specific region code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint. - Code V1CreateProjectBodyRegionSelection0Code `json:"code"` - Type V1CreateProjectBodyRegionSelection0Type `json:"type"` -} - -// V1CreateProjectBodyRegionSelection0Code Specific region code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint. -type V1CreateProjectBodyRegionSelection0Code string - -// V1CreateProjectBodyRegionSelection0Type defines model for V1CreateProjectBody.RegionSelection.0.Type. -type V1CreateProjectBodyRegionSelection0Type string - -// V1CreateProjectBodyRegionSelection1 defines model for . -type V1CreateProjectBodyRegionSelection1 struct { - // Code The Smart Region Group's code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint. - Code V1CreateProjectBodyRegionSelection1Code `json:"code"` - Type V1CreateProjectBodyRegionSelection1Type `json:"type"` -} - -// V1CreateProjectBodyRegionSelection1Code The Smart Region Group's code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint. -type V1CreateProjectBodyRegionSelection1Code string - -// V1CreateProjectBodyRegionSelection1Type defines model for V1CreateProjectBody.RegionSelection.1.Type. -type V1CreateProjectBodyRegionSelection1Type string - -// V1CreateProjectBody_RegionSelection Region selection. Only one of region or region_selection can be specified. -type V1CreateProjectBody_RegionSelection struct { - union json.RawMessage -} - -// V1GetMigrationResponseOutput defines model for V1GetMigrationResponse_Output. -type V1GetMigrationResponseOutput struct { - CreatedBy *string `json:"created_by,omitempty"` - IdempotencyKey *string `json:"idempotency_key,omitempty"` - Name *string `json:"name,omitempty"` - Rollback *[]string `json:"rollback,omitempty"` - Statements *[]string `json:"statements,omitempty"` - Version string `json:"version"` -} - -// V1GetUsageApiCountResponseOutput defines model for V1GetUsageApiCountResponse_Output. -type V1GetUsageApiCountResponseOutput struct { - Error *V1GetUsageApiCountResponseOutput_Error `json:"error,omitempty"` - Result *[]struct { - Timestamp time.Time `json:"timestamp"` - TotalAuthRequests float32 `json:"total_auth_requests"` - TotalRealtimeRequests float32 `json:"total_realtime_requests"` - TotalRestRequests float32 `json:"total_rest_requests"` - TotalStorageRequests float32 `json:"total_storage_requests"` - } `json:"result,omitempty"` -} - -// V1GetUsageApiCountResponseOutputError0 defines model for . -type V1GetUsageApiCountResponseOutputError0 = string - -// V1GetUsageApiCountResponseOutputError1 defines model for . -type V1GetUsageApiCountResponseOutputError1 struct { - Code float32 `json:"code"` - Errors []struct { - Domain string `json:"domain"` - Location string `json:"location"` - LocationType string `json:"locationType"` - Message string `json:"message"` - Reason string `json:"reason"` - } `json:"errors"` - Message string `json:"message"` - Status string `json:"status"` -} - -// V1GetUsageApiCountResponseOutput_Error defines model for V1GetUsageApiCountResponseOutput.Error. -type V1GetUsageApiCountResponseOutput_Error struct { - union json.RawMessage -} - -// V1GetUsageApiRequestsCountResponseOutput defines model for V1GetUsageApiRequestsCountResponse_Output. -type V1GetUsageApiRequestsCountResponseOutput struct { - Error *V1GetUsageApiRequestsCountResponseOutput_Error `json:"error,omitempty"` - Result *[]struct { - Count float32 `json:"count"` - } `json:"result,omitempty"` -} - -// V1GetUsageApiRequestsCountResponseOutputError0 defines model for . -type V1GetUsageApiRequestsCountResponseOutputError0 = string - -// V1GetUsageApiRequestsCountResponseOutputError1 defines model for . -type V1GetUsageApiRequestsCountResponseOutputError1 struct { - Code float32 `json:"code"` - Errors []struct { - Domain string `json:"domain"` - Location string `json:"location"` - LocationType string `json:"locationType"` - Message string `json:"message"` - Reason string `json:"reason"` - } `json:"errors"` - Message string `json:"message"` - Status string `json:"status"` -} - -// V1GetUsageApiRequestsCountResponseOutput_Error defines model for V1GetUsageApiRequestsCountResponseOutput.Error. -type V1GetUsageApiRequestsCountResponseOutput_Error struct { - union json.RawMessage -} - -// V1ListEntitlementsResponseOutput defines model for V1ListEntitlementsResponse_Output. -type V1ListEntitlementsResponseOutput struct { - Entitlements []struct { - Config V1ListEntitlementsResponseOutput_Entitlements_Config `json:"config"` - Feature struct { - Key V1ListEntitlementsResponseOutputEntitlementsFeatureKey `json:"key"` - Type V1ListEntitlementsResponseOutputEntitlementsFeatureType `json:"type"` - } `json:"feature"` - HasAccess bool `json:"hasAccess"` - Type V1ListEntitlementsResponseOutputEntitlementsType `json:"type"` - } `json:"entitlements"` -} - -// V1ListEntitlementsResponseOutputEntitlementsConfig0 defines model for . -type V1ListEntitlementsResponseOutputEntitlementsConfig0 struct { - Enabled bool `json:"enabled"` -} - -// V1ListEntitlementsResponseOutputEntitlementsConfig1 defines model for . -type V1ListEntitlementsResponseOutputEntitlementsConfig1 struct { - Enabled bool `json:"enabled"` - Unit string `json:"unit"` - Unlimited bool `json:"unlimited"` - Value float32 `json:"value"` -} - -// V1ListEntitlementsResponseOutputEntitlementsConfig2 defines model for . -type V1ListEntitlementsResponseOutputEntitlementsConfig2 struct { - Enabled bool `json:"enabled"` - Set []string `json:"set"` -} - -// V1ListEntitlementsResponseOutput_Entitlements_Config defines model for V1ListEntitlementsResponseOutput.Entitlements.Config. -type V1ListEntitlementsResponseOutput_Entitlements_Config struct { - union json.RawMessage -} - -// V1ListEntitlementsResponseOutputEntitlementsFeatureKey defines model for V1ListEntitlementsResponseOutput.Entitlements.Feature.Key. -type V1ListEntitlementsResponseOutputEntitlementsFeatureKey string - -// V1ListEntitlementsResponseOutputEntitlementsFeatureType defines model for V1ListEntitlementsResponseOutput.Entitlements.Feature.Type. -type V1ListEntitlementsResponseOutputEntitlementsFeatureType string - -// V1ListEntitlementsResponseOutputEntitlementsType defines model for V1ListEntitlementsResponseOutput.Entitlements.Type. -type V1ListEntitlementsResponseOutputEntitlementsType string - -// V1ListMigrationsResponseOutput defines model for V1ListMigrationsResponse_Output. -type V1ListMigrationsResponseOutput = []struct { - Name *string `json:"name,omitempty"` - Version string `json:"version"` -} - -// V1OrganizationMemberResponseOutput defines model for V1OrganizationMemberResponse_Output. -type V1OrganizationMemberResponseOutput struct { - AvatarUrl nullable.Nullable[string] `json:"avatar_url"` - Email *string `json:"email,omitempty"` - MfaEnabled bool `json:"mfa_enabled"` - RoleName *string `json:"role_name,omitempty"` - UserId string `json:"user_id"` - UserName string `json:"user_name"` -} - -// V1OrganizationSlugResponseOutput defines model for V1OrganizationSlugResponse_Output. -type V1OrganizationSlugResponseOutput struct { - AllowedReleaseChannels []V1OrganizationSlugResponseOutputAllowedReleaseChannels `json:"allowed_release_channels"` - Id string `json:"id"` - Name string `json:"name"` - OptInTags []interface{} `json:"opt_in_tags"` - Plan *V1OrganizationSlugResponseOutputPlan `json:"plan,omitempty"` -} - -// V1OrganizationSlugResponseOutputAllowedReleaseChannels defines model for V1OrganizationSlugResponseOutput.AllowedReleaseChannels. -type V1OrganizationSlugResponseOutputAllowedReleaseChannels string - -// V1OrganizationSlugResponseOutputPlan defines model for V1OrganizationSlugResponseOutput.Plan. -type V1OrganizationSlugResponseOutputPlan string - -// V1PatchMigrationBody defines model for V1PatchMigrationBody. -type V1PatchMigrationBody struct { - Name *string `json:"name,omitempty"` - Rollback *string `json:"rollback,omitempty"` -} - -// V1PgbouncerConfigResponseOutput defines model for V1PgbouncerConfigResponse_Output. -type V1PgbouncerConfigResponseOutput struct { - ConnectionString *string `json:"connection_string,omitempty"` - DefaultPoolSize *int `json:"default_pool_size,omitempty"` - IgnoreStartupParameters *string `json:"ignore_startup_parameters,omitempty"` - MaxClientConn *int `json:"max_client_conn,omitempty"` - PoolMode *V1PgbouncerConfigResponseOutputPoolMode `json:"pool_mode,omitempty"` - QueryWaitTimeout *int `json:"query_wait_timeout,omitempty"` - ReservePoolSize *int `json:"reserve_pool_size,omitempty"` - ServerIdleTimeout *int `json:"server_idle_timeout,omitempty"` - ServerLifetime *int `json:"server_lifetime,omitempty"` -} - -// V1PgbouncerConfigResponseOutputPoolMode defines model for V1PgbouncerConfigResponseOutput.PoolMode. -type V1PgbouncerConfigResponseOutputPoolMode string - -// V1PostgrestConfigResponseOutput defines model for V1PostgrestConfigResponse_Output. -type V1PostgrestConfigResponseOutput struct { - DbExtraSearchPath string `json:"db_extra_search_path"` - - // DbPool If `null`, the value is automatically configured based on compute size. - DbPool nullable.Nullable[int] `json:"db_pool"` - - // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. - DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` - DbSchema string `json:"db_schema"` - MaxRows int `json:"max_rows"` -} - -// V1ProfileResponseOutput defines model for V1ProfileResponse_Output. -type V1ProfileResponseOutput struct { - GotrueId string `json:"gotrue_id"` - PrimaryEmail string `json:"primary_email"` - Username string `json:"username"` -} - -// V1ProjectAdvisorsResponseOutput defines model for V1ProjectAdvisorsResponse_Output. -type V1ProjectAdvisorsResponseOutput struct { - Lints []struct { - CacheKey string `json:"cache_key"` - Categories []V1ProjectAdvisorsResponseOutputLintsCategories `json:"categories"` - Description string `json:"description"` - Detail string `json:"detail"` - Facing V1ProjectAdvisorsResponseOutputLintsFacing `json:"facing"` - Level V1ProjectAdvisorsResponseOutputLintsLevel `json:"level"` - Metadata *struct { - Entity *string `json:"entity,omitempty"` - FkeyColumns *[]float32 `json:"fkey_columns,omitempty"` - FkeyName *string `json:"fkey_name,omitempty"` - Name *string `json:"name,omitempty"` - Schema *string `json:"schema,omitempty"` - Type *V1ProjectAdvisorsResponseOutputLintsMetadataType `json:"type,omitempty"` - } `json:"metadata,omitempty"` - Name V1ProjectAdvisorsResponseOutputLintsName `json:"name"` - ObservedAt *time.Time `json:"observed_at,omitempty"` - Remediation string `json:"remediation"` - Title string `json:"title"` - } `json:"lints"` -} - -// V1ProjectAdvisorsResponseOutputLintsCategories defines model for V1ProjectAdvisorsResponseOutput.Lints.Categories. -type V1ProjectAdvisorsResponseOutputLintsCategories string - -// V1ProjectAdvisorsResponseOutputLintsFacing defines model for V1ProjectAdvisorsResponseOutput.Lints.Facing. -type V1ProjectAdvisorsResponseOutputLintsFacing string - -// V1ProjectAdvisorsResponseOutputLintsLevel defines model for V1ProjectAdvisorsResponseOutput.Lints.Level. -type V1ProjectAdvisorsResponseOutputLintsLevel string - -// V1ProjectAdvisorsResponseOutputLintsMetadataType defines model for V1ProjectAdvisorsResponseOutput.Lints.Metadata.Type. -type V1ProjectAdvisorsResponseOutputLintsMetadataType string - -// V1ProjectAdvisorsResponseOutputLintsName defines model for V1ProjectAdvisorsResponseOutput.Lints.Name. -type V1ProjectAdvisorsResponseOutputLintsName string - -// V1ProjectRefResponseOutput defines model for V1ProjectRefResponse_Output. -type V1ProjectRefResponseOutput struct { - Id int `json:"id"` - Name string `json:"name"` - Ref string `json:"ref"` -} - -// V1ProjectResponseOutput defines model for V1ProjectResponse_Output. -type V1ProjectResponseOutput struct { - // CreatedAt Creation timestamp - CreatedAt string `json:"created_at"` - - // Id Deprecated: Use `ref` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Id string `json:"id"` - - // Name Name of your project - Name string `json:"name"` - - // OrganizationId Deprecated: Use `organization_slug` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - OrganizationId string `json:"organization_id"` - - // OrganizationSlug Organization slug - OrganizationSlug string `json:"organization_slug"` - - // Ref Project ref - Ref string `json:"ref"` - - // Region Region of your project - Region string `json:"region"` - Status V1ProjectResponseOutputStatus `json:"status"` -} - -// V1ProjectResponseOutputStatus defines model for V1ProjectResponseOutput.Status. -type V1ProjectResponseOutputStatus string - -// V1ProjectWithDatabaseResponseOutput defines model for V1ProjectWithDatabaseResponse_Output. -type V1ProjectWithDatabaseResponseOutput struct { - // CreatedAt Creation timestamp - CreatedAt string `json:"created_at"` - Database struct { - // Host Database host - Host string `json:"host"` - - // PostgresEngine Database engine - PostgresEngine string `json:"postgres_engine"` - - // ReleaseChannel Release channel - ReleaseChannel string `json:"release_channel"` - - // Version Database version - Version string `json:"version"` - } `json:"database"` - - // Id Deprecated: Use `ref` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Id string `json:"id"` - - // Name Name of your project - Name string `json:"name"` - - // OrganizationId Deprecated: Use `organization_slug` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - OrganizationId string `json:"organization_id"` - - // OrganizationSlug Organization slug - OrganizationSlug string `json:"organization_slug"` - - // Ref Project ref - Ref string `json:"ref"` - - // Region Region of your project - Region string `json:"region"` - Status V1ProjectWithDatabaseResponseOutputStatus `json:"status"` -} - -// V1ProjectWithDatabaseResponseOutputStatus defines model for V1ProjectWithDatabaseResponseOutput.Status. -type V1ProjectWithDatabaseResponseOutputStatus string - -// V1ReadOnlyQueryBody defines model for V1ReadOnlyQueryBody. -type V1ReadOnlyQueryBody struct { - Parameters *[]interface{} `json:"parameters,omitempty"` - Query string `json:"query"` -} - -// V1RestoreBackupBody defines model for V1RestoreBackupBody. -type V1RestoreBackupBody struct { - Id int `json:"id"` -} - -// V1RestorePitrBody defines model for V1RestorePitrBody. -type V1RestorePitrBody struct { - RecoveryTimeTargetUnix int64 `json:"recovery_time_target_unix"` -} - -// V1RestorePointPostBody defines model for V1RestorePointPostBody. -type V1RestorePointPostBody struct { - Name string `json:"name"` -} - -// V1RestorePointResponse defines model for V1RestorePointResponse. -type V1RestorePointResponse struct { - CompletedOn nullable.Nullable[time.Time] `json:"completed_on"` - Name string `json:"name"` - Status V1RestorePointResponseStatus `json:"status"` -} - -// V1RestorePointResponseStatus defines model for V1RestorePointResponse.Status. -type V1RestorePointResponseStatus string - -// V1RunQueryBody defines model for V1RunQueryBody. -type V1RunQueryBody struct { - Parameters *[]interface{} `json:"parameters,omitempty"` - Query string `json:"query"` - ReadOnly *bool `json:"read_only,omitempty"` -} - -// V1ServiceHealthResponseOutput defines model for V1ServiceHealthResponse_Output. -type V1ServiceHealthResponseOutput struct { - Error *string `json:"error,omitempty"` - - // Healthy Deprecated. Use `status` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Healthy bool `json:"healthy"` - Info *V1ServiceHealthResponseOutput_Info `json:"info,omitempty"` - Name V1ServiceHealthResponseOutputName `json:"name"` - Status V1ServiceHealthResponseOutputStatus `json:"status"` -} - -// V1ServiceHealthResponseOutputInfo0 defines model for . -type V1ServiceHealthResponseOutputInfo0 struct { - Description string `json:"description"` - Name V1ServiceHealthResponseOutputInfo0Name `json:"name"` - Version string `json:"version"` -} - -// V1ServiceHealthResponseOutputInfo0Name defines model for V1ServiceHealthResponseOutput.Info.0.Name. -type V1ServiceHealthResponseOutputInfo0Name string - -// V1ServiceHealthResponseOutputInfo1 defines model for . -type V1ServiceHealthResponseOutputInfo1 struct { - ConnectedCluster int `json:"connected_cluster"` - DbConnected bool `json:"db_connected"` - - // Healthy Deprecated. Use `status` instead. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - Healthy bool `json:"healthy"` - ReplicationConnected bool `json:"replication_connected"` -} - -// V1ServiceHealthResponseOutputInfo2 defines model for . -type V1ServiceHealthResponseOutputInfo2 struct { - DbSchema string `json:"db_schema"` -} - -// V1ServiceHealthResponseOutput_Info defines model for V1ServiceHealthResponseOutput.Info. -type V1ServiceHealthResponseOutput_Info struct { - union json.RawMessage -} - -// V1ServiceHealthResponseOutputName defines model for V1ServiceHealthResponseOutput.Name. -type V1ServiceHealthResponseOutputName string - -// V1ServiceHealthResponseOutputStatus defines model for V1ServiceHealthResponseOutput.Status. -type V1ServiceHealthResponseOutputStatus string - -// V1StorageBucketResponseOutput defines model for V1StorageBucketResponse_Output. -type V1StorageBucketResponseOutput struct { - CreatedAt string `json:"created_at"` - Id string `json:"id"` - Name string `json:"name"` - Owner string `json:"owner"` - Public bool `json:"public"` - UpdatedAt string `json:"updated_at"` -} - -// V1UndoBody defines model for V1UndoBody. -type V1UndoBody struct { - Name string `json:"name"` -} - -// V1UpdateBackupScheduleBody defines model for V1UpdateBackupScheduleBody. -type V1UpdateBackupScheduleBody struct { - // ScheduleFor Time of day to schedule daily backups, in UTC. Format: HH:MM:SS. - ScheduleFor string `json:"schedule_for"` -} - -// V1UpdateFunctionBodyEszip Raw Brotli-compressed ESZIP binary file. -// -// **Validation requirements** - The file must start with the four magic bytes `EZBR` (hex: `45 5A 42 52`). -// - The `ezbr_sha256` query parameter must contain the SHA-256 hash of the uploaded ESZIP payload. -// -// **Example** -// ```javascript -// import { readFile } from 'node:fs/promises'; -// import { brotliCompress } from 'node:zlib'; -// import { promisify } from 'node:util'; -// import { createHash } from 'node:crypto'; -// -// const compress = promisify(brotliCompress); -// const marker = Buffer.from('EZBR', 'ascii'); -// -// const file = await readFile('./function.eszip'); -// const compressed = await compress(file); -// -// const payload = Buffer.concat([marker, compressed]); -// -// const ezbr_sha256 = createHash('sha256') -// -// .update(payload) -// .digest('hex'); -// -// console.log(ezbr_sha256); -// ``` -type V1UpdateFunctionBodyEszip = openapi_types.File - -// V1UpdateFunctionBodyJson defines model for V1UpdateFunctionBodyJson. -type V1UpdateFunctionBodyJson struct { - Body *string `json:"body,omitempty"` - Name *string `json:"name,omitempty"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` -} - -// V1UpdatePasswordBody defines model for V1UpdatePasswordBody. -type V1UpdatePasswordBody struct { - Password string `json:"password"` -} - -// V1UpdatePasswordResponseOutput defines model for V1UpdatePasswordResponse_Output. -type V1UpdatePasswordResponseOutput struct { - Message string `json:"message"` -} - -// V1UpdatePostgrestConfigBody defines model for V1UpdatePostgrestConfigBody. -type V1UpdatePostgrestConfigBody struct { - DbExtraSearchPath *string `json:"db_extra_search_path,omitempty"` - DbPool *int `json:"db_pool,omitempty"` - DbPoolAcquisitionTimeout *int `json:"db_pool_acquisition_timeout,omitempty"` - DbSchema *string `json:"db_schema,omitempty"` - MaxRows *int `json:"max_rows,omitempty"` -} - -// V1UpdateProjectBody defines model for V1UpdateProjectBody. -type V1UpdateProjectBody struct { - Name string `json:"name"` -} - -// V1UpsertMigrationBody defines model for V1UpsertMigrationBody. -type V1UpsertMigrationBody struct { - Name *string `json:"name,omitempty"` - Query string `json:"query"` - Rollback *string `json:"rollback,omitempty"` -} - -// VanitySubdomainBody defines model for VanitySubdomainBody. -type VanitySubdomainBody struct { - VanitySubdomain string `json:"vanity_subdomain"` -} - -// VanitySubdomainConfigResponseOutput defines model for VanitySubdomainConfigResponse_Output. -type VanitySubdomainConfigResponseOutput struct { - CustomDomain *string `json:"custom_domain,omitempty"` - Status VanitySubdomainConfigResponseOutputStatus `json:"status"` -} - -// VanitySubdomainConfigResponseOutputStatus defines model for VanitySubdomainConfigResponseOutput.Status. -type VanitySubdomainConfigResponseOutputStatus string - -// bearerContextKey is the context key for bearer security scheme -type bearerContextKey string - -// oauth2ContextKey is the context key for oauth2 security scheme -type oauth2ContextKey string - -// V1DeleteABranchParams defines parameters for V1DeleteABranch. -type V1DeleteABranchParams struct { - // Force If set to false, schedule deletion with 1-hour grace period (only when soft deletion is enabled). - Force *string `form:"force,omitempty" json:"force,omitempty"` -} - -// V1DiffABranchParams defines parameters for V1DiffABranch. -type V1DiffABranchParams struct { - IncludedSchemas *string `form:"included_schemas,omitempty" json:"included_schemas,omitempty"` - - // Pgdelta Use pg-delta instead of Migra for diffing when true. - // Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Pgdelta *string `form:"pgdelta,omitempty" json:"pgdelta,omitempty"` -} - -// V1AuthorizeUserParams defines parameters for V1AuthorizeUser. -type V1AuthorizeUserParams struct { - ClientId openapi_types.UUID `form:"client_id" json:"client_id"` - ResponseType V1AuthorizeUserParamsResponseType `form:"response_type" json:"response_type"` - RedirectUri string `form:"redirect_uri" json:"redirect_uri"` - Scope *string `form:"scope,omitempty" json:"scope,omitempty"` - State *string `form:"state,omitempty" json:"state,omitempty"` - ResponseMode *string `form:"response_mode,omitempty" json:"response_mode,omitempty"` - CodeChallenge *string `form:"code_challenge,omitempty" json:"code_challenge,omitempty"` - CodeChallengeMethod *V1AuthorizeUserParamsCodeChallengeMethod `form:"code_challenge_method,omitempty" json:"code_challenge_method,omitempty"` - - // OrganizationSlug Organization slug - OrganizationSlug *string `form:"organization_slug,omitempty" json:"organization_slug,omitempty"` - TargetFlow *string `form:"target_flow,omitempty" json:"target_flow,omitempty"` - - // Resource Resource indicator for MCP (Model Context Protocol) clients - Resource *string `form:"resource,omitempty" json:"resource,omitempty"` -} - -// V1AuthorizeUserParamsResponseType defines parameters for V1AuthorizeUser. -type V1AuthorizeUserParamsResponseType string - -// V1AuthorizeUserParamsCodeChallengeMethod defines parameters for V1AuthorizeUser. -type V1AuthorizeUserParamsCodeChallengeMethod string - -// V1OauthAuthorizeProjectClaimParams defines parameters for V1OauthAuthorizeProjectClaim. -type V1OauthAuthorizeProjectClaimParams struct { - // ProjectRef Project ref - ProjectRef string `form:"project_ref" json:"project_ref"` - ClientId openapi_types.UUID `form:"client_id" json:"client_id"` - ResponseType V1OauthAuthorizeProjectClaimParamsResponseType `form:"response_type" json:"response_type"` - RedirectUri string `form:"redirect_uri" json:"redirect_uri"` - State *string `form:"state,omitempty" json:"state,omitempty"` - ResponseMode *string `form:"response_mode,omitempty" json:"response_mode,omitempty"` - CodeChallenge *string `form:"code_challenge,omitempty" json:"code_challenge,omitempty"` - CodeChallengeMethod *V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod `form:"code_challenge_method,omitempty" json:"code_challenge_method,omitempty"` -} - -// V1OauthAuthorizeProjectClaimParamsResponseType defines parameters for V1OauthAuthorizeProjectClaim. -type V1OauthAuthorizeProjectClaimParamsResponseType string - -// V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod defines parameters for V1OauthAuthorizeProjectClaim. -type V1OauthAuthorizeProjectClaimParamsCodeChallengeMethod string - -// V1GetAllProjectsForOrganizationParams defines parameters for V1GetAllProjectsForOrganization. -type V1GetAllProjectsForOrganizationParams struct { - // Offset Number of projects to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - - // Limit Number of projects to return per page - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - - // Search Search projects by name - Search *string `form:"search,omitempty" json:"search,omitempty"` - - // Sort Sort order for projects - Sort *V1GetAllProjectsForOrganizationParamsSort `form:"sort,omitempty" json:"sort,omitempty"` - - // Statuses A comma-separated list of project statuses to filter by. - // - // The following values are supported: `ACTIVE_HEALTHY`, `INACTIVE`. - Statuses *string `form:"statuses,omitempty" json:"statuses,omitempty"` -} - -// V1GetAllProjectsForOrganizationParamsSort defines parameters for V1GetAllProjectsForOrganization. -type V1GetAllProjectsForOrganizationParamsSort string - -// V1GetAvailableRegionsParams defines parameters for V1GetAvailableRegions. -type V1GetAvailableRegionsParams struct { - // OrganizationSlug Slug of your organization - OrganizationSlug string `form:"organization_slug" json:"organization_slug"` - - // Continent Continent code to determine regional recommendations: NA (North America), SA (South America), EU (Europe), AF (Africa), AS (Asia), OC (Oceania), AN (Antarctica) - Continent *V1GetAvailableRegionsParamsContinent `form:"continent,omitempty" json:"continent,omitempty"` - - // DesiredInstanceSize Desired instance size. Omit this field to always default to the smallest possible size. - DesiredInstanceSize *V1GetAvailableRegionsParamsDesiredInstanceSize `form:"desired_instance_size,omitempty" json:"desired_instance_size,omitempty"` -} - -// V1GetAvailableRegionsParamsContinent defines parameters for V1GetAvailableRegions. -type V1GetAvailableRegionsParamsContinent string - -// V1GetAvailableRegionsParamsDesiredInstanceSize defines parameters for V1GetAvailableRegions. -type V1GetAvailableRegionsParamsDesiredInstanceSize string - -// V1ListActionRunsParams defines parameters for V1ListActionRuns. -type V1ListActionRunsParams struct { - Offset *float32 `form:"offset,omitempty" json:"offset,omitempty"` - Limit *float32 `form:"limit,omitempty" json:"limit,omitempty"` -} - -// V1GetSecurityAdvisorsParams defines parameters for V1GetSecurityAdvisors. -type V1GetSecurityAdvisorsParams struct { - LintType *V1GetSecurityAdvisorsParamsLintType `form:"lint_type,omitempty" json:"lint_type,omitempty"` -} - -// V1GetSecurityAdvisorsParamsLintType defines parameters for V1GetSecurityAdvisors. -type V1GetSecurityAdvisorsParamsLintType string - -// V1GetProjectFunctionCombinedStatsParams defines parameters for V1GetProjectFunctionCombinedStats. -type V1GetProjectFunctionCombinedStatsParams struct { - Interval V1GetProjectFunctionCombinedStatsParamsInterval `form:"interval" json:"interval"` - FunctionId string `form:"function_id" json:"function_id"` -} - -// V1GetProjectFunctionCombinedStatsParamsInterval defines parameters for V1GetProjectFunctionCombinedStats. -type V1GetProjectFunctionCombinedStatsParamsInterval string - -// V1GetProjectLogsParams defines parameters for V1GetProjectLogs. -type V1GetProjectLogsParams struct { - // Sql Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. - Sql *string `form:"sql,omitempty" json:"sql,omitempty"` - IsoTimestampStart *time.Time `form:"iso_timestamp_start,omitempty" json:"iso_timestamp_start,omitempty"` - IsoTimestampEnd *time.Time `form:"iso_timestamp_end,omitempty" json:"iso_timestamp_end,omitempty"` -} - -// V1GetProjectUsageApiCountParams defines parameters for V1GetProjectUsageApiCount. -type V1GetProjectUsageApiCountParams struct { - Interval *V1GetProjectUsageApiCountParamsInterval `form:"interval,omitempty" json:"interval,omitempty"` -} - -// V1GetProjectUsageApiCountParamsInterval defines parameters for V1GetProjectUsageApiCount. -type V1GetProjectUsageApiCountParamsInterval string - -// V1GetProjectApiKeysParams defines parameters for V1GetProjectApiKeys. -type V1GetProjectApiKeysParams struct { - // Reveal Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Reveal *string `form:"reveal,omitempty" json:"reveal,omitempty"` -} - -// V1CreateProjectApiKeyParams defines parameters for V1CreateProjectApiKey. -type V1CreateProjectApiKeyParams struct { - // Reveal Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Reveal *string `form:"reveal,omitempty" json:"reveal,omitempty"` -} - -// V1UpdateProjectLegacyApiKeysParams defines parameters for V1UpdateProjectLegacyApiKeys. -type V1UpdateProjectLegacyApiKeysParams struct { - // Enabled Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Enabled string `form:"enabled" json:"enabled"` -} - -// V1DeleteProjectApiKeyParams defines parameters for V1DeleteProjectApiKey. -type V1DeleteProjectApiKeyParams struct { - // Reveal Boolean string, true or false - Reveal *string `form:"reveal,omitempty" json:"reveal,omitempty"` - - // WasCompromised Boolean string, true or false - WasCompromised *string `form:"was_compromised,omitempty" json:"was_compromised,omitempty"` - Reason *string `form:"reason,omitempty" json:"reason,omitempty"` -} - -// V1GetProjectApiKeyParams defines parameters for V1GetProjectApiKey. -type V1GetProjectApiKeyParams struct { - // Reveal Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Reveal *string `form:"reveal,omitempty" json:"reveal,omitempty"` -} - -// V1UpdateProjectApiKeyParams defines parameters for V1UpdateProjectApiKey. -type V1UpdateProjectApiKeyParams struct { - // Reveal Boolean string. - // - // Truthy values: `true`, `1`, `yes`, `on`, `y`, `enabled` - // - // Falsy values: `false`, `0`, `no`, `off`, `n`, `disabled` - Reveal *string `form:"reveal,omitempty" json:"reveal,omitempty"` -} - -// V1RemoveProjectAddonParamsAddonVariant0 defines parameters for V1RemoveProjectAddon. -type V1RemoveProjectAddonParamsAddonVariant0 string - -// V1RemoveProjectAddonParamsAddonVariant1 defines parameters for V1RemoveProjectAddon. -type V1RemoveProjectAddonParamsAddonVariant1 string - -// V1RemoveProjectAddonParamsAddonVariant2 defines parameters for V1RemoveProjectAddon. -type V1RemoveProjectAddonParamsAddonVariant2 string - -// V1RemoveProjectAddonParamsAddonVariant3 defines parameters for V1RemoveProjectAddon. -type V1RemoveProjectAddonParamsAddonVariant3 string - -// V1DeleteHostnameConfigParams defines parameters for V1DeleteHostnameConfig. -type V1DeleteHostnameConfigParams struct { - // RemoveAddon If true, also removes the custom domain add-on from the project subscription. - RemoveAddon *string `form:"remove_addon,omitempty" json:"remove_addon,omitempty"` -} - -// V1GetRestorePointParams defines parameters for V1GetRestorePoint. -type V1GetRestorePointParams struct { - Name *string `form:"name,omitempty" json:"name,omitempty"` -} - -// V1RollbackMigrationsParams defines parameters for V1RollbackMigrations. -type V1RollbackMigrationsParams struct { - // Gte Rollback migrations greater or equal to this version - Gte string `form:"gte" json:"gte"` -} - -// V1ApplyAMigrationParams defines parameters for V1ApplyAMigration. -type V1ApplyAMigrationParams struct { - // IdempotencyKey A unique key to ensure the same migration is tracked only once. - IdempotencyKey *string `json:"Idempotency-Key,omitempty"` -} - -// V1UpsertAMigrationParams defines parameters for V1UpsertAMigration. -type V1UpsertAMigrationParams struct { - // IdempotencyKey A unique key to ensure the same migration is tracked only once. - IdempotencyKey *string `json:"Idempotency-Key,omitempty"` -} - -// V1GetDatabaseOpenapiParams defines parameters for V1GetDatabaseOpenapi. -type V1GetDatabaseOpenapiParams struct { - // Schema The database schema to generate the OpenAPI spec for - Schema *string `form:"schema,omitempty" json:"schema,omitempty"` -} - -// V1CreateAFunctionParams defines parameters for V1CreateAFunction. -type V1CreateAFunctionParams struct { - Slug *string `form:"slug,omitempty" json:"slug,omitempty"` - Name *string `form:"name,omitempty" json:"name,omitempty"` - VerifyJwt *string `form:"verify_jwt,omitempty" json:"verify_jwt,omitempty"` - ImportMap *string `form:"import_map,omitempty" json:"import_map,omitempty"` - EntrypointPath *string `form:"entrypoint_path,omitempty" json:"entrypoint_path,omitempty"` - ImportMapPath *string `form:"import_map_path,omitempty" json:"import_map_path,omitempty"` - EzbrSha256 *string `form:"ezbr_sha256,omitempty" json:"ezbr_sha256,omitempty"` -} - -// V1DeployAFunctionParams defines parameters for V1DeployAFunction. -type V1DeployAFunctionParams struct { - Slug *string `form:"slug,omitempty" json:"slug,omitempty"` - BundleOnly *string `form:"bundleOnly,omitempty" json:"bundleOnly,omitempty"` -} - -// V1UpdateAFunctionParams defines parameters for V1UpdateAFunction. -type V1UpdateAFunctionParams struct { - Slug *string `form:"slug,omitempty" json:"slug,omitempty"` - Name *string `form:"name,omitempty" json:"name,omitempty"` - VerifyJwt *string `form:"verify_jwt,omitempty" json:"verify_jwt,omitempty"` - ImportMap *string `form:"import_map,omitempty" json:"import_map,omitempty"` - EntrypointPath *string `form:"entrypoint_path,omitempty" json:"entrypoint_path,omitempty"` - ImportMapPath *string `form:"import_map_path,omitempty" json:"import_map_path,omitempty"` - EzbrSha256 *string `form:"ezbr_sha256,omitempty" json:"ezbr_sha256,omitempty"` -} - -// V1GetServicesHealthParams defines parameters for V1GetServicesHealth. -type V1GetServicesHealthParams struct { - // Services Comma-separated list of enums or array of enums. - Services []V1GetServicesHealthParamsServices `form:"services" json:"services"` - TimeoutMs *int `form:"timeout_ms,omitempty" json:"timeout_ms,omitempty"` -} - -// V1GetServicesHealthParamsServices defines parameters for V1GetServicesHealth. -type V1GetServicesHealthParamsServices string - -// V1GetJitAccessConfig200JSONResponseBody0 defines parameters for V1GetJitAccessConfig. -type V1GetJitAccessConfig200JSONResponseBody0 struct { - AppliedSuccessfully *bool `json:"appliedSuccessfully,omitempty"` - State V1GetJitAccessConfig200JSONResponseBody0State `json:"state"` -} - -// V1GetJitAccessConfig200JSONResponseBody0State defines parameters for V1GetJitAccessConfig. -type V1GetJitAccessConfig200JSONResponseBody0State string - -// V1GetJitAccessConfig200JSONResponseBody1 defines parameters for V1GetJitAccessConfig. -type V1GetJitAccessConfig200JSONResponseBody1 struct { - State string `json:"state"` - UnavailableReason V1GetJitAccessConfig200JSONResponseBody1UnavailableReason `json:"unavailableReason"` -} - -// V1GetJitAccessConfig200JSONResponseBody1UnavailableReason defines parameters for V1GetJitAccessConfig. -type V1GetJitAccessConfig200JSONResponseBody1UnavailableReason string - -// V1GetJitAccessConfig200JSONResponseBody defines parameters for V1GetJitAccessConfig. -type V1GetJitAccessConfig200JSONResponseBody struct { - union json.RawMessage -} - -// V1UpdateJitAccessConfig200JSONResponseBody0 defines parameters for V1UpdateJitAccessConfig. -type V1UpdateJitAccessConfig200JSONResponseBody0 struct { - AppliedSuccessfully *bool `json:"appliedSuccessfully,omitempty"` - State V1UpdateJitAccessConfig200JSONResponseBody0State `json:"state"` -} - -// V1UpdateJitAccessConfig200JSONResponseBody0State defines parameters for V1UpdateJitAccessConfig. -type V1UpdateJitAccessConfig200JSONResponseBody0State string - -// V1UpdateJitAccessConfig200JSONResponseBody1 defines parameters for V1UpdateJitAccessConfig. -type V1UpdateJitAccessConfig200JSONResponseBody1 struct { - State string `json:"state"` - UnavailableReason V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason `json:"unavailableReason"` -} - -// V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason defines parameters for V1UpdateJitAccessConfig. -type V1UpdateJitAccessConfig200JSONResponseBody1UnavailableReason string - -// V1UpdateJitAccessConfig200JSONResponseBody defines parameters for V1UpdateJitAccessConfig. -type V1UpdateJitAccessConfig200JSONResponseBody struct { - union json.RawMessage -} - -// V1GenerateTypescriptTypesParams defines parameters for V1GenerateTypescriptTypes. -type V1GenerateTypescriptTypesParams struct { - IncludedSchemas *string `form:"included_schemas,omitempty" json:"included_schemas,omitempty"` -} - -// V1GetPostgresUpgradeStatusParams defines parameters for V1GetPostgresUpgradeStatus. -type V1GetPostgresUpgradeStatusParams struct { - TrackingId *string `form:"tracking_id,omitempty" json:"tracking_id,omitempty"` -} - -// V1ListAllSnippetsParams defines parameters for V1ListAllSnippets. -type V1ListAllSnippetsParams struct { - // ProjectRef Project ref - ProjectRef *string `form:"project_ref,omitempty" json:"project_ref,omitempty"` - Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"` - Limit *string `form:"limit,omitempty" json:"limit,omitempty"` - SortBy *V1ListAllSnippetsParamsSortBy `form:"sort_by,omitempty" json:"sort_by,omitempty"` - SortOrder *V1ListAllSnippetsParamsSortOrder `form:"sort_order,omitempty" json:"sort_order,omitempty"` -} - -// V1ListAllSnippetsParamsSortBy defines parameters for V1ListAllSnippets. -type V1ListAllSnippetsParamsSortBy string - -// V1ListAllSnippetsParamsSortOrder defines parameters for V1ListAllSnippets. -type V1ListAllSnippetsParamsSortOrder string - -// V1UpdateABranchConfigJSONRequestBody defines body for V1UpdateABranchConfig for application/json ContentType. -type V1UpdateABranchConfigJSONRequestBody = UpdateBranchBody - -// V1MergeABranchJSONRequestBody defines body for V1MergeABranch for application/json ContentType. -type V1MergeABranchJSONRequestBody = BranchActionBody - -// V1PushABranchJSONRequestBody defines body for V1PushABranch for application/json ContentType. -type V1PushABranchJSONRequestBody = BranchActionBody - -// V1ResetABranchJSONRequestBody defines body for V1ResetABranch for application/json ContentType. -type V1ResetABranchJSONRequestBody = BranchActionBody - -// V1RevokeTokenJSONRequestBody defines body for V1RevokeToken for application/json ContentType. -type V1RevokeTokenJSONRequestBody = OAuthRevokeTokenBody - -// V1ExchangeOauthTokenFormdataRequestBody defines body for V1ExchangeOauthToken for application/x-www-form-urlencoded ContentType. -type V1ExchangeOauthTokenFormdataRequestBody = OAuthTokenBody - -// V1CreateAnOrganizationJSONRequestBody defines body for V1CreateAnOrganization for application/json ContentType. -type V1CreateAnOrganizationJSONRequestBody = CreateOrganizationV1 - -// V1CreateAProjectJSONRequestBody defines body for V1CreateAProject for application/json ContentType. -type V1CreateAProjectJSONRequestBody = V1CreateProjectBody - -// V1UpdateAProjectJSONRequestBody defines body for V1UpdateAProject for application/json ContentType. -type V1UpdateAProjectJSONRequestBody = V1UpdateProjectBody - -// V1UpdateActionRunStatusJSONRequestBody defines body for V1UpdateActionRunStatus for application/json ContentType. -type V1UpdateActionRunStatusJSONRequestBody = UpdateRunStatusBody - -// V1CreateProjectApiKeyJSONRequestBody defines body for V1CreateProjectApiKey for application/json ContentType. -type V1CreateProjectApiKeyJSONRequestBody = CreateApiKeyBody - -// V1UpdateProjectApiKeyJSONRequestBody defines body for V1UpdateProjectApiKey for application/json ContentType. -type V1UpdateProjectApiKeyJSONRequestBody = UpdateApiKeyBody - -// V1ApplyProjectAddonJSONRequestBody defines body for V1ApplyProjectAddon for application/json ContentType. -type V1ApplyProjectAddonJSONRequestBody = ApplyProjectAddonBody - -// V1CreateABranchJSONRequestBody defines body for V1CreateABranch for application/json ContentType. -type V1CreateABranchJSONRequestBody = CreateBranchBody - -// V1CreateLoginRoleJSONRequestBody defines body for V1CreateLoginRole for application/json ContentType. -type V1CreateLoginRoleJSONRequestBody = CreateRoleBody - -// V1UpdateAuthServiceConfigJSONRequestBody defines body for V1UpdateAuthServiceConfig for application/json ContentType. -type V1UpdateAuthServiceConfigJSONRequestBody = UpdateAuthConfigBody - -// V1CreateProjectSigningKeyJSONRequestBody defines body for V1CreateProjectSigningKey for application/json ContentType. -type V1CreateProjectSigningKeyJSONRequestBody = CreateSigningKeyBody - -// V1UpdateProjectSigningKeyJSONRequestBody defines body for V1UpdateProjectSigningKey for application/json ContentType. -type V1UpdateProjectSigningKeyJSONRequestBody = UpdateSigningKeyBody - -// V1CreateASsoProviderJSONRequestBody defines body for V1CreateASsoProvider for application/json ContentType. -type V1CreateASsoProviderJSONRequestBody = CreateProviderBody - -// V1UpdateASsoProviderJSONRequestBody defines body for V1UpdateASsoProvider for application/json ContentType. -type V1UpdateASsoProviderJSONRequestBody = UpdateProviderBody - -// V1CreateProjectTpaIntegrationJSONRequestBody defines body for V1CreateProjectTpaIntegration for application/json ContentType. -type V1CreateProjectTpaIntegrationJSONRequestBody = CreateThirdPartyAuthBody - -// V1UpdatePoolerConfigJSONRequestBody defines body for V1UpdatePoolerConfig for application/json ContentType. -type V1UpdatePoolerConfigJSONRequestBody = UpdateSupavisorConfigBody - -// V1UpdatePostgresConfigJSONRequestBody defines body for V1UpdatePostgresConfig for application/json ContentType. -type V1UpdatePostgresConfigJSONRequestBody = UpdatePostgresConfigBody - -// V1ModifyDatabaseDiskJSONRequestBody defines body for V1ModifyDatabaseDisk for application/json ContentType. -type V1ModifyDatabaseDiskJSONRequestBody = DiskRequestBody - -// V1UpdateRealtimeConfigJSONRequestBody defines body for V1UpdateRealtimeConfig for application/json ContentType. -type V1UpdateRealtimeConfigJSONRequestBody = UpdateRealtimeConfigBody - -// V1UpdateStorageConfigJSONRequestBody defines body for V1UpdateStorageConfig for application/json ContentType. -type V1UpdateStorageConfigJSONRequestBody = UpdateStorageConfigBody - -// V1UpdateHostnameConfigJSONRequestBody defines body for V1UpdateHostnameConfig for application/json ContentType. -type V1UpdateHostnameConfigJSONRequestBody = UpdateCustomHostnameBody - -// V1RestorePhysicalBackupJSONRequestBody defines body for V1RestorePhysicalBackup for application/json ContentType. -type V1RestorePhysicalBackupJSONRequestBody = V1RestoreBackupBody - -// V1RestorePitrBackupJSONRequestBody defines body for V1RestorePitrBackup for application/json ContentType. -type V1RestorePitrBackupJSONRequestBody = V1RestorePitrBody - -// V1CreateRestorePointJSONRequestBody defines body for V1CreateRestorePoint for application/json ContentType. -type V1CreateRestorePointJSONRequestBody = V1RestorePointPostBody - -// V1UpdateBackupScheduleJSONRequestBody defines body for V1UpdateBackupSchedule for application/json ContentType. -type V1UpdateBackupScheduleJSONRequestBody = V1UpdateBackupScheduleBody - -// V1UndoJSONRequestBody defines body for V1Undo for application/json ContentType. -type V1UndoJSONRequestBody = V1UndoBody - -// V1AuthorizeJitAccessJSONRequestBody defines body for V1AuthorizeJitAccess for application/json ContentType. -type V1AuthorizeJitAccessJSONRequestBody = AuthorizeJitAccessBody - -// V1UpdateJitAccessJSONRequestBody defines body for V1UpdateJitAccess for application/json ContentType. -type V1UpdateJitAccessJSONRequestBody = UpdateJitAccessBody - -// V1InviteExternalJitAccessJSONRequestBody defines body for V1InviteExternalJitAccess for application/json ContentType. -type V1InviteExternalJitAccessJSONRequestBody = InviteExternalUserJitAccessBody - -// V1AcceptInviteExternalJitAccessJSONRequestBody defines body for V1AcceptInviteExternalJitAccess for application/json ContentType. -type V1AcceptInviteExternalJitAccessJSONRequestBody = AcceptInviteExternalUserJitAccessBody - -// V1ApplyAMigrationJSONRequestBody defines body for V1ApplyAMigration for application/json ContentType. -type V1ApplyAMigrationJSONRequestBody = V1CreateMigrationBody - -// V1UpsertAMigrationJSONRequestBody defines body for V1UpsertAMigration for application/json ContentType. -type V1UpsertAMigrationJSONRequestBody = V1UpsertMigrationBody - -// V1PatchAMigrationJSONRequestBody defines body for V1PatchAMigration for application/json ContentType. -type V1PatchAMigrationJSONRequestBody = V1PatchMigrationBody - -// V1UpdateDatabasePasswordJSONRequestBody defines body for V1UpdateDatabasePassword for application/json ContentType. -type V1UpdateDatabasePasswordJSONRequestBody = V1UpdatePasswordBody - -// V1RunAQueryJSONRequestBody defines body for V1RunAQuery for application/json ContentType. -type V1RunAQueryJSONRequestBody = V1RunQueryBody - -// V1ReadOnlyQueryJSONRequestBody defines body for V1ReadOnlyQuery for application/json ContentType. -type V1ReadOnlyQueryJSONRequestBody = V1ReadOnlyQueryBody - -// V1CreateAFunctionJSONRequestBody defines body for V1CreateAFunction for application/json ContentType. -type V1CreateAFunctionJSONRequestBody = V1CreateFunctionBody - -// V1BulkUpdateFunctionsJSONRequestBody defines body for V1BulkUpdateFunctions for application/json ContentType. -type V1BulkUpdateFunctionsJSONRequestBody = BulkUpdateFunctionBody - -// V1DeployAFunctionMultipartRequestBody defines body for V1DeployAFunction for multipart/form-data ContentType. -type V1DeployAFunctionMultipartRequestBody = FunctionDeployBody - -// V1UpdateAFunctionJSONRequestBody defines body for V1UpdateAFunction for application/json ContentType. -type V1UpdateAFunctionJSONRequestBody = V1UpdateFunctionBodyJson - -// V1UpdateJitAccessConfigJSONRequestBody defines body for V1UpdateJitAccessConfig for application/json ContentType. -type V1UpdateJitAccessConfigJSONRequestBody = JitAccessRequestRequest - -// V1DeleteNetworkBansJSONRequestBody defines body for V1DeleteNetworkBans for application/json ContentType. -type V1DeleteNetworkBansJSONRequestBody = RemoveNetworkBanRequest - -// V1PatchNetworkRestrictionsJSONRequestBody defines body for V1PatchNetworkRestrictions for application/json ContentType. -type V1PatchNetworkRestrictionsJSONRequestBody = NetworkRestrictionsPatchRequest - -// V1UpdateNetworkRestrictionsJSONRequestBody defines body for V1UpdateNetworkRestrictions for application/json ContentType. -type V1UpdateNetworkRestrictionsJSONRequestBody = NetworkRestrictionsRequest - -// V1UpdatePgsodiumConfigJSONRequestBody defines body for V1UpdatePgsodiumConfig for application/json ContentType. -type V1UpdatePgsodiumConfigJSONRequestBody = UpdatePgsodiumConfigBody - -// V1UpdatePostgrestServiceConfigJSONRequestBody defines body for V1UpdatePostgrestServiceConfig for application/json ContentType. -type V1UpdatePostgrestServiceConfigJSONRequestBody = V1UpdatePostgrestConfigBody - -// V1RemoveAReadReplicaJSONRequestBody defines body for V1RemoveAReadReplica for application/json ContentType. -type V1RemoveAReadReplicaJSONRequestBody = RemoveReadReplicaBody - -// V1SetupAReadReplicaJSONRequestBody defines body for V1SetupAReadReplica for application/json ContentType. -type V1SetupAReadReplicaJSONRequestBody = SetUpReadReplicaBody - -// V1BulkDeleteSecretsJSONRequestBody defines body for V1BulkDeleteSecrets for application/json ContentType. -type V1BulkDeleteSecretsJSONRequestBody = DeleteSecretsBody - -// V1BulkCreateSecretsJSONRequestBody defines body for V1BulkCreateSecrets for application/json ContentType. -type V1BulkCreateSecretsJSONRequestBody = CreateSecretBody - -// V1UpdateSslEnforcementConfigJSONRequestBody defines body for V1UpdateSslEnforcementConfig for application/json ContentType. -type V1UpdateSslEnforcementConfigJSONRequestBody = SslEnforcementRequest - -// V1UpgradePostgresVersionJSONRequestBody defines body for V1UpgradePostgresVersion for application/json ContentType. -type V1UpgradePostgresVersionJSONRequestBody = UpgradeDatabaseBody - -// V1ActivateVanitySubdomainConfigJSONRequestBody defines body for V1ActivateVanitySubdomainConfig for application/json ContentType. -type V1ActivateVanitySubdomainConfigJSONRequestBody = VanitySubdomainBody - -// V1CheckVanitySubdomainAvailabilityJSONRequestBody defines body for V1CheckVanitySubdomainAvailability for application/json ContentType. -type V1CheckVanitySubdomainAvailabilityJSONRequestBody = VanitySubdomainBody - -// AsAnalyticsResponseOutputError0 returns the union data inside the AnalyticsResponseOutput_Error as a AnalyticsResponseOutputError0 -func (t AnalyticsResponseOutput_Error) AsAnalyticsResponseOutputError0() (AnalyticsResponseOutputError0, error) { - var body AnalyticsResponseOutputError0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromAnalyticsResponseOutputError0 overwrites any union data inside the AnalyticsResponseOutput_Error as the provided AnalyticsResponseOutputError0 -func (t *AnalyticsResponseOutput_Error) FromAnalyticsResponseOutputError0(v AnalyticsResponseOutputError0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeAnalyticsResponseOutputError0 performs a merge with any union data inside the AnalyticsResponseOutput_Error, using the provided AnalyticsResponseOutputError0 -func (t *AnalyticsResponseOutput_Error) MergeAnalyticsResponseOutputError0(v AnalyticsResponseOutputError0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsAnalyticsResponseOutputError1 returns the union data inside the AnalyticsResponseOutput_Error as a AnalyticsResponseOutputError1 -func (t AnalyticsResponseOutput_Error) AsAnalyticsResponseOutputError1() (AnalyticsResponseOutputError1, error) { - var body AnalyticsResponseOutputError1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromAnalyticsResponseOutputError1 overwrites any union data inside the AnalyticsResponseOutput_Error as the provided AnalyticsResponseOutputError1 -func (t *AnalyticsResponseOutput_Error) FromAnalyticsResponseOutputError1(v AnalyticsResponseOutputError1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeAnalyticsResponseOutputError1 performs a merge with any union data inside the AnalyticsResponseOutput_Error, using the provided AnalyticsResponseOutputError1 -func (t *AnalyticsResponseOutput_Error) MergeAnalyticsResponseOutputError1(v AnalyticsResponseOutputError1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t AnalyticsResponseOutput_Error) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *AnalyticsResponseOutput_Error) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsApplyProjectAddonBodyAddonVariant0 returns the union data inside the ApplyProjectAddonBody_AddonVariant as a ApplyProjectAddonBodyAddonVariant0 -func (t ApplyProjectAddonBody_AddonVariant) AsApplyProjectAddonBodyAddonVariant0() (ApplyProjectAddonBodyAddonVariant0, error) { - var body ApplyProjectAddonBodyAddonVariant0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromApplyProjectAddonBodyAddonVariant0 overwrites any union data inside the ApplyProjectAddonBody_AddonVariant as the provided ApplyProjectAddonBodyAddonVariant0 -func (t *ApplyProjectAddonBody_AddonVariant) FromApplyProjectAddonBodyAddonVariant0(v ApplyProjectAddonBodyAddonVariant0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeApplyProjectAddonBodyAddonVariant0 performs a merge with any union data inside the ApplyProjectAddonBody_AddonVariant, using the provided ApplyProjectAddonBodyAddonVariant0 -func (t *ApplyProjectAddonBody_AddonVariant) MergeApplyProjectAddonBodyAddonVariant0(v ApplyProjectAddonBodyAddonVariant0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsApplyProjectAddonBodyAddonVariant1 returns the union data inside the ApplyProjectAddonBody_AddonVariant as a ApplyProjectAddonBodyAddonVariant1 -func (t ApplyProjectAddonBody_AddonVariant) AsApplyProjectAddonBodyAddonVariant1() (ApplyProjectAddonBodyAddonVariant1, error) { - var body ApplyProjectAddonBodyAddonVariant1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromApplyProjectAddonBodyAddonVariant1 overwrites any union data inside the ApplyProjectAddonBody_AddonVariant as the provided ApplyProjectAddonBodyAddonVariant1 -func (t *ApplyProjectAddonBody_AddonVariant) FromApplyProjectAddonBodyAddonVariant1(v ApplyProjectAddonBodyAddonVariant1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeApplyProjectAddonBodyAddonVariant1 performs a merge with any union data inside the ApplyProjectAddonBody_AddonVariant, using the provided ApplyProjectAddonBodyAddonVariant1 -func (t *ApplyProjectAddonBody_AddonVariant) MergeApplyProjectAddonBodyAddonVariant1(v ApplyProjectAddonBodyAddonVariant1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsApplyProjectAddonBodyAddonVariant2 returns the union data inside the ApplyProjectAddonBody_AddonVariant as a ApplyProjectAddonBodyAddonVariant2 -func (t ApplyProjectAddonBody_AddonVariant) AsApplyProjectAddonBodyAddonVariant2() (ApplyProjectAddonBodyAddonVariant2, error) { - var body ApplyProjectAddonBodyAddonVariant2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromApplyProjectAddonBodyAddonVariant2 overwrites any union data inside the ApplyProjectAddonBody_AddonVariant as the provided ApplyProjectAddonBodyAddonVariant2 -func (t *ApplyProjectAddonBody_AddonVariant) FromApplyProjectAddonBodyAddonVariant2(v ApplyProjectAddonBodyAddonVariant2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeApplyProjectAddonBodyAddonVariant2 performs a merge with any union data inside the ApplyProjectAddonBody_AddonVariant, using the provided ApplyProjectAddonBodyAddonVariant2 -func (t *ApplyProjectAddonBody_AddonVariant) MergeApplyProjectAddonBodyAddonVariant2(v ApplyProjectAddonBodyAddonVariant2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsApplyProjectAddonBodyAddonVariant3 returns the union data inside the ApplyProjectAddonBody_AddonVariant as a ApplyProjectAddonBodyAddonVariant3 -func (t ApplyProjectAddonBody_AddonVariant) AsApplyProjectAddonBodyAddonVariant3() (ApplyProjectAddonBodyAddonVariant3, error) { - var body ApplyProjectAddonBodyAddonVariant3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromApplyProjectAddonBodyAddonVariant3 overwrites any union data inside the ApplyProjectAddonBody_AddonVariant as the provided ApplyProjectAddonBodyAddonVariant3 -func (t *ApplyProjectAddonBody_AddonVariant) FromApplyProjectAddonBodyAddonVariant3(v ApplyProjectAddonBodyAddonVariant3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeApplyProjectAddonBodyAddonVariant3 performs a merge with any union data inside the ApplyProjectAddonBody_AddonVariant, using the provided ApplyProjectAddonBodyAddonVariant3 -func (t *ApplyProjectAddonBody_AddonVariant) MergeApplyProjectAddonBodyAddonVariant3(v ApplyProjectAddonBodyAddonVariant3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ApplyProjectAddonBody_AddonVariant) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ApplyProjectAddonBody_AddonVariant) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsAuthorizeJitAccessBodyRhost0 returns the union data inside the AuthorizeJitAccessBody_Rhost as a AuthorizeJitAccessBodyRhost0 -func (t AuthorizeJitAccessBody_Rhost) AsAuthorizeJitAccessBodyRhost0() (AuthorizeJitAccessBodyRhost0, error) { - var body AuthorizeJitAccessBodyRhost0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromAuthorizeJitAccessBodyRhost0 overwrites any union data inside the AuthorizeJitAccessBody_Rhost as the provided AuthorizeJitAccessBodyRhost0 -func (t *AuthorizeJitAccessBody_Rhost) FromAuthorizeJitAccessBodyRhost0(v AuthorizeJitAccessBodyRhost0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeAuthorizeJitAccessBodyRhost0 performs a merge with any union data inside the AuthorizeJitAccessBody_Rhost, using the provided AuthorizeJitAccessBodyRhost0 -func (t *AuthorizeJitAccessBody_Rhost) MergeAuthorizeJitAccessBodyRhost0(v AuthorizeJitAccessBodyRhost0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsAuthorizeJitAccessBodyRhost1 returns the union data inside the AuthorizeJitAccessBody_Rhost as a AuthorizeJitAccessBodyRhost1 -func (t AuthorizeJitAccessBody_Rhost) AsAuthorizeJitAccessBodyRhost1() (AuthorizeJitAccessBodyRhost1, error) { - var body AuthorizeJitAccessBodyRhost1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromAuthorizeJitAccessBodyRhost1 overwrites any union data inside the AuthorizeJitAccessBody_Rhost as the provided AuthorizeJitAccessBodyRhost1 -func (t *AuthorizeJitAccessBody_Rhost) FromAuthorizeJitAccessBodyRhost1(v AuthorizeJitAccessBodyRhost1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeAuthorizeJitAccessBodyRhost1 performs a merge with any union data inside the AuthorizeJitAccessBody_Rhost, using the provided AuthorizeJitAccessBodyRhost1 -func (t *AuthorizeJitAccessBody_Rhost) MergeAuthorizeJitAccessBodyRhost1(v AuthorizeJitAccessBodyRhost1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t AuthorizeJitAccessBody_Rhost) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *AuthorizeJitAccessBody_Rhost) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsCreateSigningKeyBodyPrivateJwk0 returns the union data inside the CreateSigningKeyBody_PrivateJwk as a CreateSigningKeyBodyPrivateJwk0 -func (t CreateSigningKeyBody_PrivateJwk) AsCreateSigningKeyBodyPrivateJwk0() (CreateSigningKeyBodyPrivateJwk0, error) { - var body CreateSigningKeyBodyPrivateJwk0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromCreateSigningKeyBodyPrivateJwk0 overwrites any union data inside the CreateSigningKeyBody_PrivateJwk as the provided CreateSigningKeyBodyPrivateJwk0 -func (t *CreateSigningKeyBody_PrivateJwk) FromCreateSigningKeyBodyPrivateJwk0(v CreateSigningKeyBodyPrivateJwk0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeCreateSigningKeyBodyPrivateJwk0 performs a merge with any union data inside the CreateSigningKeyBody_PrivateJwk, using the provided CreateSigningKeyBodyPrivateJwk0 -func (t *CreateSigningKeyBody_PrivateJwk) MergeCreateSigningKeyBodyPrivateJwk0(v CreateSigningKeyBodyPrivateJwk0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsCreateSigningKeyBodyPrivateJwk1 returns the union data inside the CreateSigningKeyBody_PrivateJwk as a CreateSigningKeyBodyPrivateJwk1 -func (t CreateSigningKeyBody_PrivateJwk) AsCreateSigningKeyBodyPrivateJwk1() (CreateSigningKeyBodyPrivateJwk1, error) { - var body CreateSigningKeyBodyPrivateJwk1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromCreateSigningKeyBodyPrivateJwk1 overwrites any union data inside the CreateSigningKeyBody_PrivateJwk as the provided CreateSigningKeyBodyPrivateJwk1 -func (t *CreateSigningKeyBody_PrivateJwk) FromCreateSigningKeyBodyPrivateJwk1(v CreateSigningKeyBodyPrivateJwk1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeCreateSigningKeyBodyPrivateJwk1 performs a merge with any union data inside the CreateSigningKeyBody_PrivateJwk, using the provided CreateSigningKeyBodyPrivateJwk1 -func (t *CreateSigningKeyBody_PrivateJwk) MergeCreateSigningKeyBodyPrivateJwk1(v CreateSigningKeyBodyPrivateJwk1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsCreateSigningKeyBodyPrivateJwk2 returns the union data inside the CreateSigningKeyBody_PrivateJwk as a CreateSigningKeyBodyPrivateJwk2 -func (t CreateSigningKeyBody_PrivateJwk) AsCreateSigningKeyBodyPrivateJwk2() (CreateSigningKeyBodyPrivateJwk2, error) { - var body CreateSigningKeyBodyPrivateJwk2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromCreateSigningKeyBodyPrivateJwk2 overwrites any union data inside the CreateSigningKeyBody_PrivateJwk as the provided CreateSigningKeyBodyPrivateJwk2 -func (t *CreateSigningKeyBody_PrivateJwk) FromCreateSigningKeyBodyPrivateJwk2(v CreateSigningKeyBodyPrivateJwk2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeCreateSigningKeyBodyPrivateJwk2 performs a merge with any union data inside the CreateSigningKeyBody_PrivateJwk, using the provided CreateSigningKeyBodyPrivateJwk2 -func (t *CreateSigningKeyBody_PrivateJwk) MergeCreateSigningKeyBodyPrivateJwk2(v CreateSigningKeyBodyPrivateJwk2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsCreateSigningKeyBodyPrivateJwk3 returns the union data inside the CreateSigningKeyBody_PrivateJwk as a CreateSigningKeyBodyPrivateJwk3 -func (t CreateSigningKeyBody_PrivateJwk) AsCreateSigningKeyBodyPrivateJwk3() (CreateSigningKeyBodyPrivateJwk3, error) { - var body CreateSigningKeyBodyPrivateJwk3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromCreateSigningKeyBodyPrivateJwk3 overwrites any union data inside the CreateSigningKeyBody_PrivateJwk as the provided CreateSigningKeyBodyPrivateJwk3 -func (t *CreateSigningKeyBody_PrivateJwk) FromCreateSigningKeyBodyPrivateJwk3(v CreateSigningKeyBodyPrivateJwk3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeCreateSigningKeyBodyPrivateJwk3 performs a merge with any union data inside the CreateSigningKeyBody_PrivateJwk, using the provided CreateSigningKeyBodyPrivateJwk3 -func (t *CreateSigningKeyBody_PrivateJwk) MergeCreateSigningKeyBodyPrivateJwk3(v CreateSigningKeyBodyPrivateJwk3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t CreateSigningKeyBody_PrivateJwk) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *CreateSigningKeyBody_PrivateJwk) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsDiskRequestBodyAttributes0 returns the union data inside the DiskRequestBody_Attributes as a DiskRequestBodyAttributes0 -func (t DiskRequestBody_Attributes) AsDiskRequestBodyAttributes0() (DiskRequestBodyAttributes0, error) { - var body DiskRequestBodyAttributes0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromDiskRequestBodyAttributes0 overwrites any union data inside the DiskRequestBody_Attributes as the provided DiskRequestBodyAttributes0 -func (t *DiskRequestBody_Attributes) FromDiskRequestBodyAttributes0(v DiskRequestBodyAttributes0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeDiskRequestBodyAttributes0 performs a merge with any union data inside the DiskRequestBody_Attributes, using the provided DiskRequestBodyAttributes0 -func (t *DiskRequestBody_Attributes) MergeDiskRequestBodyAttributes0(v DiskRequestBodyAttributes0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsDiskRequestBodyAttributes1 returns the union data inside the DiskRequestBody_Attributes as a DiskRequestBodyAttributes1 -func (t DiskRequestBody_Attributes) AsDiskRequestBodyAttributes1() (DiskRequestBodyAttributes1, error) { - var body DiskRequestBodyAttributes1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromDiskRequestBodyAttributes1 overwrites any union data inside the DiskRequestBody_Attributes as the provided DiskRequestBodyAttributes1 -func (t *DiskRequestBody_Attributes) FromDiskRequestBodyAttributes1(v DiskRequestBodyAttributes1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeDiskRequestBodyAttributes1 performs a merge with any union data inside the DiskRequestBody_Attributes, using the provided DiskRequestBodyAttributes1 -func (t *DiskRequestBody_Attributes) MergeDiskRequestBodyAttributes1(v DiskRequestBodyAttributes1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t DiskRequestBody_Attributes) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *DiskRequestBody_Attributes) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsDiskResponseOutputAttributes0 returns the union data inside the DiskResponseOutput_Attributes as a DiskResponseOutputAttributes0 -func (t DiskResponseOutput_Attributes) AsDiskResponseOutputAttributes0() (DiskResponseOutputAttributes0, error) { - var body DiskResponseOutputAttributes0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromDiskResponseOutputAttributes0 overwrites any union data inside the DiskResponseOutput_Attributes as the provided DiskResponseOutputAttributes0 -func (t *DiskResponseOutput_Attributes) FromDiskResponseOutputAttributes0(v DiskResponseOutputAttributes0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeDiskResponseOutputAttributes0 performs a merge with any union data inside the DiskResponseOutput_Attributes, using the provided DiskResponseOutputAttributes0 -func (t *DiskResponseOutput_Attributes) MergeDiskResponseOutputAttributes0(v DiskResponseOutputAttributes0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsDiskResponseOutputAttributes1 returns the union data inside the DiskResponseOutput_Attributes as a DiskResponseOutputAttributes1 -func (t DiskResponseOutput_Attributes) AsDiskResponseOutputAttributes1() (DiskResponseOutputAttributes1, error) { - var body DiskResponseOutputAttributes1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromDiskResponseOutputAttributes1 overwrites any union data inside the DiskResponseOutput_Attributes as the provided DiskResponseOutputAttributes1 -func (t *DiskResponseOutput_Attributes) FromDiskResponseOutputAttributes1(v DiskResponseOutputAttributes1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeDiskResponseOutputAttributes1 performs a merge with any union data inside the DiskResponseOutput_Attributes, using the provided DiskResponseOutputAttributes1 -func (t *DiskResponseOutput_Attributes) MergeDiskResponseOutputAttributes1(v DiskResponseOutputAttributes1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t DiskResponseOutput_Attributes) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *DiskResponseOutput_Attributes) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsJitListAccessResponseOutputItems0 returns the union data inside the JitListAccessResponseOutput_Items_Item as a JitListAccessResponseOutputItems0 -func (t JitListAccessResponseOutput_Items_Item) AsJitListAccessResponseOutputItems0() (JitListAccessResponseOutputItems0, error) { - var body JitListAccessResponseOutputItems0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJitListAccessResponseOutputItems0 overwrites any union data inside the JitListAccessResponseOutput_Items_Item as the provided JitListAccessResponseOutputItems0 -func (t *JitListAccessResponseOutput_Items_Item) FromJitListAccessResponseOutputItems0(v JitListAccessResponseOutputItems0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJitListAccessResponseOutputItems0 performs a merge with any union data inside the JitListAccessResponseOutput_Items_Item, using the provided JitListAccessResponseOutputItems0 -func (t *JitListAccessResponseOutput_Items_Item) MergeJitListAccessResponseOutputItems0(v JitListAccessResponseOutputItems0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsJitListAccessResponseOutputItems1 returns the union data inside the JitListAccessResponseOutput_Items_Item as a JitListAccessResponseOutputItems1 -func (t JitListAccessResponseOutput_Items_Item) AsJitListAccessResponseOutputItems1() (JitListAccessResponseOutputItems1, error) { - var body JitListAccessResponseOutputItems1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJitListAccessResponseOutputItems1 overwrites any union data inside the JitListAccessResponseOutput_Items_Item as the provided JitListAccessResponseOutputItems1 -func (t *JitListAccessResponseOutput_Items_Item) FromJitListAccessResponseOutputItems1(v JitListAccessResponseOutputItems1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJitListAccessResponseOutputItems1 performs a merge with any union data inside the JitListAccessResponseOutput_Items_Item, using the provided JitListAccessResponseOutputItems1 -func (t *JitListAccessResponseOutput_Items_Item) MergeJitListAccessResponseOutputItems1(v JitListAccessResponseOutputItems1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t JitListAccessResponseOutput_Items_Item) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *JitListAccessResponseOutput_Items_Item) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsJsonValueOutput0 returns the union data inside the JsonValueOutput as a JsonValueOutput0 -func (t JsonValueOutput) AsJsonValueOutput0() (JsonValueOutput0, error) { - var body JsonValueOutput0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput0 overwrites any union data inside the JsonValueOutput as the provided JsonValueOutput0 -func (t *JsonValueOutput) FromJsonValueOutput0(v JsonValueOutput0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput0 performs a merge with any union data inside the JsonValueOutput, using the provided JsonValueOutput0 -func (t *JsonValueOutput) MergeJsonValueOutput0(v JsonValueOutput0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsJsonValueOutput1 returns the union data inside the JsonValueOutput as a JsonValueOutput1 -func (t JsonValueOutput) AsJsonValueOutput1() (JsonValueOutput1, error) { - var body JsonValueOutput1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput1 overwrites any union data inside the JsonValueOutput as the provided JsonValueOutput1 -func (t *JsonValueOutput) FromJsonValueOutput1(v JsonValueOutput1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput1 performs a merge with any union data inside the JsonValueOutput, using the provided JsonValueOutput1 -func (t *JsonValueOutput) MergeJsonValueOutput1(v JsonValueOutput1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsJsonValueOutput2 returns the union data inside the JsonValueOutput as a JsonValueOutput2 -func (t JsonValueOutput) AsJsonValueOutput2() (JsonValueOutput2, error) { - var body JsonValueOutput2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput2 overwrites any union data inside the JsonValueOutput as the provided JsonValueOutput2 -func (t *JsonValueOutput) FromJsonValueOutput2(v JsonValueOutput2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput2 performs a merge with any union data inside the JsonValueOutput, using the provided JsonValueOutput2 -func (t *JsonValueOutput) MergeJsonValueOutput2(v JsonValueOutput2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t JsonValueOutput) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *JsonValueOutput) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsJsonValueOutput00 returns the union data inside the JsonValueOutput0 as a JsonValueOutput00 -func (t JsonValueOutput0) AsJsonValueOutput00() (JsonValueOutput00, error) { - var body JsonValueOutput00 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput00 overwrites any union data inside the JsonValueOutput0 as the provided JsonValueOutput00 -func (t *JsonValueOutput0) FromJsonValueOutput00(v JsonValueOutput00) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput00 performs a merge with any union data inside the JsonValueOutput0, using the provided JsonValueOutput00 -func (t *JsonValueOutput0) MergeJsonValueOutput00(v JsonValueOutput00) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsJsonValueOutput01 returns the union data inside the JsonValueOutput0 as a JsonValueOutput01 -func (t JsonValueOutput0) AsJsonValueOutput01() (JsonValueOutput01, error) { - var body JsonValueOutput01 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput01 overwrites any union data inside the JsonValueOutput0 as the provided JsonValueOutput01 -func (t *JsonValueOutput0) FromJsonValueOutput01(v JsonValueOutput01) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput01 performs a merge with any union data inside the JsonValueOutput0, using the provided JsonValueOutput01 -func (t *JsonValueOutput0) MergeJsonValueOutput01(v JsonValueOutput01) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsJsonValueOutput02 returns the union data inside the JsonValueOutput0 as a JsonValueOutput02 -func (t JsonValueOutput0) AsJsonValueOutput02() (JsonValueOutput02, error) { - var body JsonValueOutput02 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromJsonValueOutput02 overwrites any union data inside the JsonValueOutput0 as the provided JsonValueOutput02 -func (t *JsonValueOutput0) FromJsonValueOutput02(v JsonValueOutput02) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeJsonValueOutput02 performs a merge with any union data inside the JsonValueOutput0, using the provided JsonValueOutput02 -func (t *JsonValueOutput0) MergeJsonValueOutput02(v JsonValueOutput02) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t JsonValueOutput0) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *JsonValueOutput0) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId0 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId0() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId0, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId0 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId0(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId0 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId0 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId0(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId1 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId1() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId1, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId1 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId1(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId1 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId1 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId1(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId2 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId2() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId2, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId2 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId2(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId2 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId2 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId2(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId3 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId3() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId3, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId3 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId3(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId3 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId3 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId3(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId4 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId4() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId4, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId4 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId4(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId4) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId4 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId4 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId4(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId4) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId5 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId5() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId5, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId5 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId5(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId5) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId5 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId5 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId5(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId5) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId6 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId6() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId6, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId6 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId6(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId6) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId6 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId6 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId6(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId6) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputAvailableAddonsVariantsId7 returns the union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as a ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) AsListProjectAddonsResponseOutputAvailableAddonsVariantsId7() (ListProjectAddonsResponseOutputAvailableAddonsVariantsId7, error) { - var body ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputAvailableAddonsVariantsId7 overwrites any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) FromListProjectAddonsResponseOutputAvailableAddonsVariantsId7(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId7) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId7 performs a merge with any union data inside the ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseOutputAvailableAddonsVariantsId7 -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseOutputAvailableAddonsVariantsId7(v ListProjectAddonsResponseOutputAvailableAddonsVariantsId7) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ListProjectAddonsResponseOutput_AvailableAddons_Variants_Id) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId0 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId0 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId0() (ListProjectAddonsResponseOutputSelectedAddonsVariantId0, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId0 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId0 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId0(v ListProjectAddonsResponseOutputSelectedAddonsVariantId0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId0 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId0 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId0(v ListProjectAddonsResponseOutputSelectedAddonsVariantId0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId1 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId1 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId1() (ListProjectAddonsResponseOutputSelectedAddonsVariantId1, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId1 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId1 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId1(v ListProjectAddonsResponseOutputSelectedAddonsVariantId1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId1 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId1 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId1(v ListProjectAddonsResponseOutputSelectedAddonsVariantId1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId2 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId2 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId2() (ListProjectAddonsResponseOutputSelectedAddonsVariantId2, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId2 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId2 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId2(v ListProjectAddonsResponseOutputSelectedAddonsVariantId2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId2 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId2 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId2(v ListProjectAddonsResponseOutputSelectedAddonsVariantId2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId3 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId3 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId3() (ListProjectAddonsResponseOutputSelectedAddonsVariantId3, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId3 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId3 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId3(v ListProjectAddonsResponseOutputSelectedAddonsVariantId3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId3 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId3 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId3(v ListProjectAddonsResponseOutputSelectedAddonsVariantId3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId4 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId4 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId4() (ListProjectAddonsResponseOutputSelectedAddonsVariantId4, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId4 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId4 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId4 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId4(v ListProjectAddonsResponseOutputSelectedAddonsVariantId4) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId4 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId4 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId4(v ListProjectAddonsResponseOutputSelectedAddonsVariantId4) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId5 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId5 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId5() (ListProjectAddonsResponseOutputSelectedAddonsVariantId5, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId5 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId5 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId5 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId5(v ListProjectAddonsResponseOutputSelectedAddonsVariantId5) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId5 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId5 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId5(v ListProjectAddonsResponseOutputSelectedAddonsVariantId5) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId6 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId6 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId6() (ListProjectAddonsResponseOutputSelectedAddonsVariantId6, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId6 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId6 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId6 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId6(v ListProjectAddonsResponseOutputSelectedAddonsVariantId6) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId6 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId6 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId6(v ListProjectAddonsResponseOutputSelectedAddonsVariantId6) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsListProjectAddonsResponseOutputSelectedAddonsVariantId7 returns the union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as a ListProjectAddonsResponseOutputSelectedAddonsVariantId7 -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) AsListProjectAddonsResponseOutputSelectedAddonsVariantId7() (ListProjectAddonsResponseOutputSelectedAddonsVariantId7, error) { - var body ListProjectAddonsResponseOutputSelectedAddonsVariantId7 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromListProjectAddonsResponseOutputSelectedAddonsVariantId7 overwrites any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId7 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) FromListProjectAddonsResponseOutputSelectedAddonsVariantId7(v ListProjectAddonsResponseOutputSelectedAddonsVariantId7) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeListProjectAddonsResponseOutputSelectedAddonsVariantId7 performs a merge with any union data inside the ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseOutputSelectedAddonsVariantId7 -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseOutputSelectedAddonsVariantId7(v ListProjectAddonsResponseOutputSelectedAddonsVariantId7) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ListProjectAddonsResponseOutput_SelectedAddons_Variant_Id) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType as a ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) AsProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0() (ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) FromProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0(v ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) MergeProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0(v ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType as a ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) AsProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1() (ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) FromProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1(v ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) MergeProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1(v ProjectUpgradeEligibilityResponseOutputValidationErrors6ObjType1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_6_ObjType) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors0 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors0 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors0() (ProjectUpgradeEligibilityResponseOutputValidationErrors0, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors0 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors0 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors0(v ProjectUpgradeEligibilityResponseOutputValidationErrors0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors0 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors0 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors0(v ProjectUpgradeEligibilityResponseOutputValidationErrors0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors1 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors1 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors1() (ProjectUpgradeEligibilityResponseOutputValidationErrors1, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors1 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors1 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors1(v ProjectUpgradeEligibilityResponseOutputValidationErrors1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors1 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors1 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors1(v ProjectUpgradeEligibilityResponseOutputValidationErrors1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors2 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors2 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors2() (ProjectUpgradeEligibilityResponseOutputValidationErrors2, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors2 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors2 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors2(v ProjectUpgradeEligibilityResponseOutputValidationErrors2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors2 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors2 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors2(v ProjectUpgradeEligibilityResponseOutputValidationErrors2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors3 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors3 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors3() (ProjectUpgradeEligibilityResponseOutputValidationErrors3, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors3 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors3 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors3(v ProjectUpgradeEligibilityResponseOutputValidationErrors3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors3 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors3 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors3(v ProjectUpgradeEligibilityResponseOutputValidationErrors3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors4 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors4 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors4() (ProjectUpgradeEligibilityResponseOutputValidationErrors4, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors4 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors4 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors4 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors4(v ProjectUpgradeEligibilityResponseOutputValidationErrors4) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors4 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors4 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors4(v ProjectUpgradeEligibilityResponseOutputValidationErrors4) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors5 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors5 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors5() (ProjectUpgradeEligibilityResponseOutputValidationErrors5, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors5 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors5 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors5 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors5(v ProjectUpgradeEligibilityResponseOutputValidationErrors5) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors5 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors5 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors5(v ProjectUpgradeEligibilityResponseOutputValidationErrors5) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors6 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors6 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors6() (ProjectUpgradeEligibilityResponseOutputValidationErrors6, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors6 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors6 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors6(v ProjectUpgradeEligibilityResponseOutputValidationErrors6) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors6 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors6 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors6(v ProjectUpgradeEligibilityResponseOutputValidationErrors6) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors7 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors7 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors7() (ProjectUpgradeEligibilityResponseOutputValidationErrors7, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors7 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors7 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors7 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors7(v ProjectUpgradeEligibilityResponseOutputValidationErrors7) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors7 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors7 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors7(v ProjectUpgradeEligibilityResponseOutputValidationErrors7) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors8 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors8 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors8() (ProjectUpgradeEligibilityResponseOutputValidationErrors8, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors8 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors8 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors8 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors8(v ProjectUpgradeEligibilityResponseOutputValidationErrors8) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors8 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors8 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors8(v ProjectUpgradeEligibilityResponseOutputValidationErrors8) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputValidationErrors9 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as a ProjectUpgradeEligibilityResponseOutputValidationErrors9 -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) AsProjectUpgradeEligibilityResponseOutputValidationErrors9() (ProjectUpgradeEligibilityResponseOutputValidationErrors9, error) { - var body ProjectUpgradeEligibilityResponseOutputValidationErrors9 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputValidationErrors9 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item as the provided ProjectUpgradeEligibilityResponseOutputValidationErrors9 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) FromProjectUpgradeEligibilityResponseOutputValidationErrors9(v ProjectUpgradeEligibilityResponseOutputValidationErrors9) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputValidationErrors9 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item, using the provided ProjectUpgradeEligibilityResponseOutputValidationErrors9 -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MergeProjectUpgradeEligibilityResponseOutputValidationErrors9(v ProjectUpgradeEligibilityResponseOutputValidationErrors9) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ProjectUpgradeEligibilityResponseOutput_ValidationErrors_Item) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsProjectUpgradeEligibilityResponseOutputWarnings0 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as a ProjectUpgradeEligibilityResponseOutputWarnings0 -func (t ProjectUpgradeEligibilityResponseOutput_Warnings_Item) AsProjectUpgradeEligibilityResponseOutputWarnings0() (ProjectUpgradeEligibilityResponseOutputWarnings0, error) { - var body ProjectUpgradeEligibilityResponseOutputWarnings0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputWarnings0 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as the provided ProjectUpgradeEligibilityResponseOutputWarnings0 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) FromProjectUpgradeEligibilityResponseOutputWarnings0(v ProjectUpgradeEligibilityResponseOutputWarnings0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputWarnings0 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item, using the provided ProjectUpgradeEligibilityResponseOutputWarnings0 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) MergeProjectUpgradeEligibilityResponseOutputWarnings0(v ProjectUpgradeEligibilityResponseOutputWarnings0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputWarnings1 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as a ProjectUpgradeEligibilityResponseOutputWarnings1 -func (t ProjectUpgradeEligibilityResponseOutput_Warnings_Item) AsProjectUpgradeEligibilityResponseOutputWarnings1() (ProjectUpgradeEligibilityResponseOutputWarnings1, error) { - var body ProjectUpgradeEligibilityResponseOutputWarnings1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputWarnings1 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as the provided ProjectUpgradeEligibilityResponseOutputWarnings1 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) FromProjectUpgradeEligibilityResponseOutputWarnings1(v ProjectUpgradeEligibilityResponseOutputWarnings1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputWarnings1 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item, using the provided ProjectUpgradeEligibilityResponseOutputWarnings1 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) MergeProjectUpgradeEligibilityResponseOutputWarnings1(v ProjectUpgradeEligibilityResponseOutputWarnings1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputWarnings2 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as a ProjectUpgradeEligibilityResponseOutputWarnings2 -func (t ProjectUpgradeEligibilityResponseOutput_Warnings_Item) AsProjectUpgradeEligibilityResponseOutputWarnings2() (ProjectUpgradeEligibilityResponseOutputWarnings2, error) { - var body ProjectUpgradeEligibilityResponseOutputWarnings2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputWarnings2 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as the provided ProjectUpgradeEligibilityResponseOutputWarnings2 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) FromProjectUpgradeEligibilityResponseOutputWarnings2(v ProjectUpgradeEligibilityResponseOutputWarnings2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputWarnings2 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item, using the provided ProjectUpgradeEligibilityResponseOutputWarnings2 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) MergeProjectUpgradeEligibilityResponseOutputWarnings2(v ProjectUpgradeEligibilityResponseOutputWarnings2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsProjectUpgradeEligibilityResponseOutputWarnings3 returns the union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as a ProjectUpgradeEligibilityResponseOutputWarnings3 -func (t ProjectUpgradeEligibilityResponseOutput_Warnings_Item) AsProjectUpgradeEligibilityResponseOutputWarnings3() (ProjectUpgradeEligibilityResponseOutputWarnings3, error) { - var body ProjectUpgradeEligibilityResponseOutputWarnings3 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromProjectUpgradeEligibilityResponseOutputWarnings3 overwrites any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item as the provided ProjectUpgradeEligibilityResponseOutputWarnings3 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) FromProjectUpgradeEligibilityResponseOutputWarnings3(v ProjectUpgradeEligibilityResponseOutputWarnings3) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeProjectUpgradeEligibilityResponseOutputWarnings3 performs a merge with any union data inside the ProjectUpgradeEligibilityResponseOutput_Warnings_Item, using the provided ProjectUpgradeEligibilityResponseOutputWarnings3 -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) MergeProjectUpgradeEligibilityResponseOutputWarnings3(v ProjectUpgradeEligibilityResponseOutputWarnings3) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t ProjectUpgradeEligibilityResponseOutput_Warnings_Item) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *ProjectUpgradeEligibilityResponseOutput_Warnings_Item) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1CreateProjectBodyRegionSelection0 returns the union data inside the V1CreateProjectBody_RegionSelection as a V1CreateProjectBodyRegionSelection0 -func (t V1CreateProjectBody_RegionSelection) AsV1CreateProjectBodyRegionSelection0() (V1CreateProjectBodyRegionSelection0, error) { - var body V1CreateProjectBodyRegionSelection0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1CreateProjectBodyRegionSelection0 overwrites any union data inside the V1CreateProjectBody_RegionSelection as the provided V1CreateProjectBodyRegionSelection0 -func (t *V1CreateProjectBody_RegionSelection) FromV1CreateProjectBodyRegionSelection0(v V1CreateProjectBodyRegionSelection0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1CreateProjectBodyRegionSelection0 performs a merge with any union data inside the V1CreateProjectBody_RegionSelection, using the provided V1CreateProjectBodyRegionSelection0 -func (t *V1CreateProjectBody_RegionSelection) MergeV1CreateProjectBodyRegionSelection0(v V1CreateProjectBodyRegionSelection0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1CreateProjectBodyRegionSelection1 returns the union data inside the V1CreateProjectBody_RegionSelection as a V1CreateProjectBodyRegionSelection1 -func (t V1CreateProjectBody_RegionSelection) AsV1CreateProjectBodyRegionSelection1() (V1CreateProjectBodyRegionSelection1, error) { - var body V1CreateProjectBodyRegionSelection1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1CreateProjectBodyRegionSelection1 overwrites any union data inside the V1CreateProjectBody_RegionSelection as the provided V1CreateProjectBodyRegionSelection1 -func (t *V1CreateProjectBody_RegionSelection) FromV1CreateProjectBodyRegionSelection1(v V1CreateProjectBodyRegionSelection1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1CreateProjectBodyRegionSelection1 performs a merge with any union data inside the V1CreateProjectBody_RegionSelection, using the provided V1CreateProjectBodyRegionSelection1 -func (t *V1CreateProjectBody_RegionSelection) MergeV1CreateProjectBodyRegionSelection1(v V1CreateProjectBodyRegionSelection1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1CreateProjectBody_RegionSelection) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1CreateProjectBody_RegionSelection) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1GetUsageApiCountResponseOutputError0 returns the union data inside the V1GetUsageApiCountResponseOutput_Error as a V1GetUsageApiCountResponseOutputError0 -func (t V1GetUsageApiCountResponseOutput_Error) AsV1GetUsageApiCountResponseOutputError0() (V1GetUsageApiCountResponseOutputError0, error) { - var body V1GetUsageApiCountResponseOutputError0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetUsageApiCountResponseOutputError0 overwrites any union data inside the V1GetUsageApiCountResponseOutput_Error as the provided V1GetUsageApiCountResponseOutputError0 -func (t *V1GetUsageApiCountResponseOutput_Error) FromV1GetUsageApiCountResponseOutputError0(v V1GetUsageApiCountResponseOutputError0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetUsageApiCountResponseOutputError0 performs a merge with any union data inside the V1GetUsageApiCountResponseOutput_Error, using the provided V1GetUsageApiCountResponseOutputError0 -func (t *V1GetUsageApiCountResponseOutput_Error) MergeV1GetUsageApiCountResponseOutputError0(v V1GetUsageApiCountResponseOutputError0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1GetUsageApiCountResponseOutputError1 returns the union data inside the V1GetUsageApiCountResponseOutput_Error as a V1GetUsageApiCountResponseOutputError1 -func (t V1GetUsageApiCountResponseOutput_Error) AsV1GetUsageApiCountResponseOutputError1() (V1GetUsageApiCountResponseOutputError1, error) { - var body V1GetUsageApiCountResponseOutputError1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetUsageApiCountResponseOutputError1 overwrites any union data inside the V1GetUsageApiCountResponseOutput_Error as the provided V1GetUsageApiCountResponseOutputError1 -func (t *V1GetUsageApiCountResponseOutput_Error) FromV1GetUsageApiCountResponseOutputError1(v V1GetUsageApiCountResponseOutputError1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetUsageApiCountResponseOutputError1 performs a merge with any union data inside the V1GetUsageApiCountResponseOutput_Error, using the provided V1GetUsageApiCountResponseOutputError1 -func (t *V1GetUsageApiCountResponseOutput_Error) MergeV1GetUsageApiCountResponseOutputError1(v V1GetUsageApiCountResponseOutputError1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1GetUsageApiCountResponseOutput_Error) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1GetUsageApiCountResponseOutput_Error) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1GetUsageApiRequestsCountResponseOutputError0 returns the union data inside the V1GetUsageApiRequestsCountResponseOutput_Error as a V1GetUsageApiRequestsCountResponseOutputError0 -func (t V1GetUsageApiRequestsCountResponseOutput_Error) AsV1GetUsageApiRequestsCountResponseOutputError0() (V1GetUsageApiRequestsCountResponseOutputError0, error) { - var body V1GetUsageApiRequestsCountResponseOutputError0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetUsageApiRequestsCountResponseOutputError0 overwrites any union data inside the V1GetUsageApiRequestsCountResponseOutput_Error as the provided V1GetUsageApiRequestsCountResponseOutputError0 -func (t *V1GetUsageApiRequestsCountResponseOutput_Error) FromV1GetUsageApiRequestsCountResponseOutputError0(v V1GetUsageApiRequestsCountResponseOutputError0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetUsageApiRequestsCountResponseOutputError0 performs a merge with any union data inside the V1GetUsageApiRequestsCountResponseOutput_Error, using the provided V1GetUsageApiRequestsCountResponseOutputError0 -func (t *V1GetUsageApiRequestsCountResponseOutput_Error) MergeV1GetUsageApiRequestsCountResponseOutputError0(v V1GetUsageApiRequestsCountResponseOutputError0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1GetUsageApiRequestsCountResponseOutputError1 returns the union data inside the V1GetUsageApiRequestsCountResponseOutput_Error as a V1GetUsageApiRequestsCountResponseOutputError1 -func (t V1GetUsageApiRequestsCountResponseOutput_Error) AsV1GetUsageApiRequestsCountResponseOutputError1() (V1GetUsageApiRequestsCountResponseOutputError1, error) { - var body V1GetUsageApiRequestsCountResponseOutputError1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetUsageApiRequestsCountResponseOutputError1 overwrites any union data inside the V1GetUsageApiRequestsCountResponseOutput_Error as the provided V1GetUsageApiRequestsCountResponseOutputError1 -func (t *V1GetUsageApiRequestsCountResponseOutput_Error) FromV1GetUsageApiRequestsCountResponseOutputError1(v V1GetUsageApiRequestsCountResponseOutputError1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetUsageApiRequestsCountResponseOutputError1 performs a merge with any union data inside the V1GetUsageApiRequestsCountResponseOutput_Error, using the provided V1GetUsageApiRequestsCountResponseOutputError1 -func (t *V1GetUsageApiRequestsCountResponseOutput_Error) MergeV1GetUsageApiRequestsCountResponseOutputError1(v V1GetUsageApiRequestsCountResponseOutputError1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1GetUsageApiRequestsCountResponseOutput_Error) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1GetUsageApiRequestsCountResponseOutput_Error) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1ListEntitlementsResponseOutputEntitlementsConfig0 returns the union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as a V1ListEntitlementsResponseOutputEntitlementsConfig0 -func (t V1ListEntitlementsResponseOutput_Entitlements_Config) AsV1ListEntitlementsResponseOutputEntitlementsConfig0() (V1ListEntitlementsResponseOutputEntitlementsConfig0, error) { - var body V1ListEntitlementsResponseOutputEntitlementsConfig0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ListEntitlementsResponseOutputEntitlementsConfig0 overwrites any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as the provided V1ListEntitlementsResponseOutputEntitlementsConfig0 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) FromV1ListEntitlementsResponseOutputEntitlementsConfig0(v V1ListEntitlementsResponseOutputEntitlementsConfig0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ListEntitlementsResponseOutputEntitlementsConfig0 performs a merge with any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config, using the provided V1ListEntitlementsResponseOutputEntitlementsConfig0 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) MergeV1ListEntitlementsResponseOutputEntitlementsConfig0(v V1ListEntitlementsResponseOutputEntitlementsConfig0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1ListEntitlementsResponseOutputEntitlementsConfig1 returns the union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as a V1ListEntitlementsResponseOutputEntitlementsConfig1 -func (t V1ListEntitlementsResponseOutput_Entitlements_Config) AsV1ListEntitlementsResponseOutputEntitlementsConfig1() (V1ListEntitlementsResponseOutputEntitlementsConfig1, error) { - var body V1ListEntitlementsResponseOutputEntitlementsConfig1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ListEntitlementsResponseOutputEntitlementsConfig1 overwrites any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as the provided V1ListEntitlementsResponseOutputEntitlementsConfig1 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) FromV1ListEntitlementsResponseOutputEntitlementsConfig1(v V1ListEntitlementsResponseOutputEntitlementsConfig1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ListEntitlementsResponseOutputEntitlementsConfig1 performs a merge with any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config, using the provided V1ListEntitlementsResponseOutputEntitlementsConfig1 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) MergeV1ListEntitlementsResponseOutputEntitlementsConfig1(v V1ListEntitlementsResponseOutputEntitlementsConfig1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1ListEntitlementsResponseOutputEntitlementsConfig2 returns the union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as a V1ListEntitlementsResponseOutputEntitlementsConfig2 -func (t V1ListEntitlementsResponseOutput_Entitlements_Config) AsV1ListEntitlementsResponseOutputEntitlementsConfig2() (V1ListEntitlementsResponseOutputEntitlementsConfig2, error) { - var body V1ListEntitlementsResponseOutputEntitlementsConfig2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ListEntitlementsResponseOutputEntitlementsConfig2 overwrites any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config as the provided V1ListEntitlementsResponseOutputEntitlementsConfig2 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) FromV1ListEntitlementsResponseOutputEntitlementsConfig2(v V1ListEntitlementsResponseOutputEntitlementsConfig2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ListEntitlementsResponseOutputEntitlementsConfig2 performs a merge with any union data inside the V1ListEntitlementsResponseOutput_Entitlements_Config, using the provided V1ListEntitlementsResponseOutputEntitlementsConfig2 -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) MergeV1ListEntitlementsResponseOutputEntitlementsConfig2(v V1ListEntitlementsResponseOutputEntitlementsConfig2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1ListEntitlementsResponseOutput_Entitlements_Config) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1ListEntitlementsResponseOutput_Entitlements_Config) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1ServiceHealthResponseOutputInfo0 returns the union data inside the V1ServiceHealthResponseOutput_Info as a V1ServiceHealthResponseOutputInfo0 -func (t V1ServiceHealthResponseOutput_Info) AsV1ServiceHealthResponseOutputInfo0() (V1ServiceHealthResponseOutputInfo0, error) { - var body V1ServiceHealthResponseOutputInfo0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ServiceHealthResponseOutputInfo0 overwrites any union data inside the V1ServiceHealthResponseOutput_Info as the provided V1ServiceHealthResponseOutputInfo0 -func (t *V1ServiceHealthResponseOutput_Info) FromV1ServiceHealthResponseOutputInfo0(v V1ServiceHealthResponseOutputInfo0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ServiceHealthResponseOutputInfo0 performs a merge with any union data inside the V1ServiceHealthResponseOutput_Info, using the provided V1ServiceHealthResponseOutputInfo0 -func (t *V1ServiceHealthResponseOutput_Info) MergeV1ServiceHealthResponseOutputInfo0(v V1ServiceHealthResponseOutputInfo0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1ServiceHealthResponseOutputInfo1 returns the union data inside the V1ServiceHealthResponseOutput_Info as a V1ServiceHealthResponseOutputInfo1 -func (t V1ServiceHealthResponseOutput_Info) AsV1ServiceHealthResponseOutputInfo1() (V1ServiceHealthResponseOutputInfo1, error) { - var body V1ServiceHealthResponseOutputInfo1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ServiceHealthResponseOutputInfo1 overwrites any union data inside the V1ServiceHealthResponseOutput_Info as the provided V1ServiceHealthResponseOutputInfo1 -func (t *V1ServiceHealthResponseOutput_Info) FromV1ServiceHealthResponseOutputInfo1(v V1ServiceHealthResponseOutputInfo1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ServiceHealthResponseOutputInfo1 performs a merge with any union data inside the V1ServiceHealthResponseOutput_Info, using the provided V1ServiceHealthResponseOutputInfo1 -func (t *V1ServiceHealthResponseOutput_Info) MergeV1ServiceHealthResponseOutputInfo1(v V1ServiceHealthResponseOutputInfo1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1ServiceHealthResponseOutputInfo2 returns the union data inside the V1ServiceHealthResponseOutput_Info as a V1ServiceHealthResponseOutputInfo2 -func (t V1ServiceHealthResponseOutput_Info) AsV1ServiceHealthResponseOutputInfo2() (V1ServiceHealthResponseOutputInfo2, error) { - var body V1ServiceHealthResponseOutputInfo2 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1ServiceHealthResponseOutputInfo2 overwrites any union data inside the V1ServiceHealthResponseOutput_Info as the provided V1ServiceHealthResponseOutputInfo2 -func (t *V1ServiceHealthResponseOutput_Info) FromV1ServiceHealthResponseOutputInfo2(v V1ServiceHealthResponseOutputInfo2) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1ServiceHealthResponseOutputInfo2 performs a merge with any union data inside the V1ServiceHealthResponseOutput_Info, using the provided V1ServiceHealthResponseOutputInfo2 -func (t *V1ServiceHealthResponseOutput_Info) MergeV1ServiceHealthResponseOutputInfo2(v V1ServiceHealthResponseOutputInfo2) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1ServiceHealthResponseOutput_Info) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1ServiceHealthResponseOutput_Info) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1GetJitAccessConfig200JSONResponseBody0 returns the union data inside the V1GetJitAccessConfig200JSONResponseBody as a V1GetJitAccessConfig200JSONResponseBody0 -func (t V1GetJitAccessConfig200JSONResponseBody) AsV1GetJitAccessConfig200JSONResponseBody0() (V1GetJitAccessConfig200JSONResponseBody0, error) { - var body V1GetJitAccessConfig200JSONResponseBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetJitAccessConfig200JSONResponseBody0 overwrites any union data inside the V1GetJitAccessConfig200JSONResponseBody as the provided V1GetJitAccessConfig200JSONResponseBody0 -func (t *V1GetJitAccessConfig200JSONResponseBody) FromV1GetJitAccessConfig200JSONResponseBody0(v V1GetJitAccessConfig200JSONResponseBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetJitAccessConfig200JSONResponseBody0 performs a merge with any union data inside the V1GetJitAccessConfig200JSONResponseBody, using the provided V1GetJitAccessConfig200JSONResponseBody0 -func (t *V1GetJitAccessConfig200JSONResponseBody) MergeV1GetJitAccessConfig200JSONResponseBody0(v V1GetJitAccessConfig200JSONResponseBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1GetJitAccessConfig200JSONResponseBody1 returns the union data inside the V1GetJitAccessConfig200JSONResponseBody as a V1GetJitAccessConfig200JSONResponseBody1 -func (t V1GetJitAccessConfig200JSONResponseBody) AsV1GetJitAccessConfig200JSONResponseBody1() (V1GetJitAccessConfig200JSONResponseBody1, error) { - var body V1GetJitAccessConfig200JSONResponseBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1GetJitAccessConfig200JSONResponseBody1 overwrites any union data inside the V1GetJitAccessConfig200JSONResponseBody as the provided V1GetJitAccessConfig200JSONResponseBody1 -func (t *V1GetJitAccessConfig200JSONResponseBody) FromV1GetJitAccessConfig200JSONResponseBody1(v V1GetJitAccessConfig200JSONResponseBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1GetJitAccessConfig200JSONResponseBody1 performs a merge with any union data inside the V1GetJitAccessConfig200JSONResponseBody, using the provided V1GetJitAccessConfig200JSONResponseBody1 -func (t *V1GetJitAccessConfig200JSONResponseBody) MergeV1GetJitAccessConfig200JSONResponseBody1(v V1GetJitAccessConfig200JSONResponseBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1GetJitAccessConfig200JSONResponseBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1GetJitAccessConfig200JSONResponseBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} - -// AsV1UpdateJitAccessConfig200JSONResponseBody0 returns the union data inside the V1UpdateJitAccessConfig200JSONResponseBody as a V1UpdateJitAccessConfig200JSONResponseBody0 -func (t V1UpdateJitAccessConfig200JSONResponseBody) AsV1UpdateJitAccessConfig200JSONResponseBody0() (V1UpdateJitAccessConfig200JSONResponseBody0, error) { - var body V1UpdateJitAccessConfig200JSONResponseBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1UpdateJitAccessConfig200JSONResponseBody0 overwrites any union data inside the V1UpdateJitAccessConfig200JSONResponseBody as the provided V1UpdateJitAccessConfig200JSONResponseBody0 -func (t *V1UpdateJitAccessConfig200JSONResponseBody) FromV1UpdateJitAccessConfig200JSONResponseBody0(v V1UpdateJitAccessConfig200JSONResponseBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1UpdateJitAccessConfig200JSONResponseBody0 performs a merge with any union data inside the V1UpdateJitAccessConfig200JSONResponseBody, using the provided V1UpdateJitAccessConfig200JSONResponseBody0 -func (t *V1UpdateJitAccessConfig200JSONResponseBody) MergeV1UpdateJitAccessConfig200JSONResponseBody0(v V1UpdateJitAccessConfig200JSONResponseBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV1UpdateJitAccessConfig200JSONResponseBody1 returns the union data inside the V1UpdateJitAccessConfig200JSONResponseBody as a V1UpdateJitAccessConfig200JSONResponseBody1 -func (t V1UpdateJitAccessConfig200JSONResponseBody) AsV1UpdateJitAccessConfig200JSONResponseBody1() (V1UpdateJitAccessConfig200JSONResponseBody1, error) { - var body V1UpdateJitAccessConfig200JSONResponseBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV1UpdateJitAccessConfig200JSONResponseBody1 overwrites any union data inside the V1UpdateJitAccessConfig200JSONResponseBody as the provided V1UpdateJitAccessConfig200JSONResponseBody1 -func (t *V1UpdateJitAccessConfig200JSONResponseBody) FromV1UpdateJitAccessConfig200JSONResponseBody1(v V1UpdateJitAccessConfig200JSONResponseBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV1UpdateJitAccessConfig200JSONResponseBody1 performs a merge with any union data inside the V1UpdateJitAccessConfig200JSONResponseBody, using the provided V1UpdateJitAccessConfig200JSONResponseBody1 -func (t *V1UpdateJitAccessConfig200JSONResponseBody) MergeV1UpdateJitAccessConfig200JSONResponseBody1(v V1UpdateJitAccessConfig200JSONResponseBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V1UpdateJitAccessConfig200JSONResponseBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - return b, err -} - -func (t *V1UpdateJitAccessConfig200JSONResponseBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - return err -} diff --git a/apps/cli-go/pkg/cast/cast.go b/apps/cli-go/pkg/cast/cast.go deleted file mode 100644 index c4038e1815..0000000000 --- a/apps/cli-go/pkg/cast/cast.go +++ /dev/null @@ -1,62 +0,0 @@ -package cast - -import ( - "math" - "strconv" -) - -// UintToInt converts a uint to an int, handling potential overflow -func UintToInt(value uint) int { - if value <= math.MaxInt { - return int(value) - } - return math.MaxInt -} - -// UIntToUInt16 converts a uint to an uint16, handling potential overflow -func UIntToUInt16(value uint) uint16 { - if value <= math.MaxUint16 { - return uint16(value) - } - return math.MaxUint16 -} - -// IntToUint converts an int to a uint, handling negative values -func IntToUint(value int) uint { - if value < 0 { - return 0 - } - return uint(value) -} - -func UintToIntPtr(value *uint) *int { - if value == nil { - return nil - } - return Ptr(UintToInt(*value)) -} - -func IntToUintPtr(value *int) *uint { - if value == nil { - return nil - } - return Ptr(IntToUint(*value)) -} - -func BoolToStringPtr(value *bool) *string { - if value == nil { - return nil - } - return Ptr(strconv.FormatBool(*value)) -} - -func Ptr[T any](v T) *T { - return &v -} - -func Val[T any](v *T, def T) T { - if v == nil { - return def - } - return *v -} diff --git a/apps/cli-go/pkg/config/api.go b/apps/cli-go/pkg/config/api.go deleted file mode 100644 index 2c97565b7b..0000000000 --- a/apps/cli-go/pkg/config/api.go +++ /dev/null @@ -1,106 +0,0 @@ -package config - -import ( - "strings" - - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/diff" -) - -type ( - api struct { - Enabled bool `toml:"enabled" json:"enabled"` - Schemas []string `toml:"schemas" json:"schemas"` - ExtraSearchPath []string `toml:"extra_search_path" json:"extra_search_path"` - MaxRows uint `toml:"max_rows" json:"max_rows"` - // When unset (default), new tables, views, sequences and functions created in the - // `public` schema by `postgres` are NOT automatically reachable through the Data API - // roles `anon`, `authenticated`, and `service_role`; explicit GRANTs are required, - // matching the new cloud default. Set to true to keep the legacy behaviour of - // auto-exposing new entities (deprecated; the field is removed on 2026-10-30). Set to - // false to pin the new default explicitly. Stored as a pointer so the migration path - // (unset -> default true before May 30 2026, default false after, removed in October) - // can flip the implicit value without losing the explicit user choice. - AutoExposeNewTables *bool `toml:"auto_expose_new_tables,omitempty" json:"auto_expose_new_tables,omitempty"` - // Local only config - Image string `toml:"-" json:"-"` - KongImage string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - Tls tlsKong `toml:"tls" json:"tls"` - // TODO: replace [auth|studio].api_url - ExternalUrl string `toml:"external_url" json:"external_url"` - } - - tlsKong struct { - Enabled bool `toml:"enabled" json:"enabled"` - CertPath string `toml:"cert_path" json:"cert_path"` - CertContent []byte `toml:"-" json:"-"` - KeyPath string `toml:"key_path" json:"key_path"` - KeyContent []byte `toml:"-" json:"-"` - } -) - -func (a *api) ToUpdatePostgrestConfigBody() v1API.V1UpdatePostgrestConfigBody { - body := v1API.V1UpdatePostgrestConfigBody{} - - // When the api is disabled, remote side it just set the dbSchema to an empty value - if !a.Enabled { - body.DbSchema = cast.Ptr("") - return body - } - - // Convert Schemas to a comma-separated string - if len(a.Schemas) > 0 { - schemas := strings.Join(a.Schemas, ",") - body.DbSchema = &schemas - } - - // Convert ExtraSearchPath to a comma-separated string - body.DbExtraSearchPath = cast.Ptr(strings.Join(a.ExtraSearchPath, ",")) - - // Convert MaxRows to int pointer - if a.MaxRows > 0 { - body.MaxRows = cast.Ptr(cast.UintToInt(a.MaxRows)) - } - - // Note: DbPool is not present in the Api struct, so it's not set here - return body -} - -func (a *api) FromRemoteApiConfig(remoteConfig v1API.PostgrestConfigWithJWTSecretResponseOutput) { - if a.Enabled = len(remoteConfig.DbSchema) > 0; !a.Enabled { - return - } - - // Update Schemas if present in remoteConfig - a.Schemas = strToArr(remoteConfig.DbSchema) - // TODO: use slices.Map when upgrade go version - for i, schema := range a.Schemas { - a.Schemas[i] = strings.TrimSpace(schema) - } - - // Update ExtraSearchPath if present in remoteConfig - a.ExtraSearchPath = strToArr(remoteConfig.DbExtraSearchPath) - for i, path := range a.ExtraSearchPath { - a.ExtraSearchPath[i] = strings.TrimSpace(path) - } - - // Update MaxRows if present in remoteConfig - a.MaxRows = cast.IntToUint(remoteConfig.MaxRows) -} - -func (a *api) DiffWithRemote(remoteConfig v1API.PostgrestConfigWithJWTSecretResponseOutput) ([]byte, error) { - copy := *a - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - copy.FromRemoteApiConfig(remoteConfig) - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[api]", remoteCompare, "local[api]", currentValue), nil -} diff --git a/apps/cli-go/pkg/config/api_test.go b/apps/cli-go/pkg/config/api_test.go deleted file mode 100644 index 8ce75c5365..0000000000 --- a/apps/cli-go/pkg/config/api_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package config - -import ( - "bytes" - "io" - "os" - "testing" - fs "testing/fstest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - v1API "github.com/supabase/cli/pkg/api" -) - -func TestApiToUpdatePostgrestConfigBody(t *testing.T) { - t.Run("converts all fields correctly", func(t *testing.T) { - api := &api{ - Enabled: true, - Schemas: []string{"public", "private"}, - ExtraSearchPath: []string{"extensions", "public"}, - MaxRows: 1000, - } - - body := api.ToUpdatePostgrestConfigBody() - - assert.Equal(t, "public,private", *body.DbSchema) - assert.Equal(t, "extensions,public", *body.DbExtraSearchPath) - assert.Equal(t, 1000, *body.MaxRows) - }) - - t.Run("handles empty fields", func(t *testing.T) { - api := &api{} - - body := api.ToUpdatePostgrestConfigBody() - - // remote api will be false by default, leading to an empty schema on api side - assert.Equal(t, "", *body.DbSchema) - }) -} - -func TestApiDiff(t *testing.T) { - t.Run("detects differences", func(t *testing.T) { - api := &api{ - Enabled: true, - Schemas: []string{"public", "private"}, - ExtraSearchPath: []string{"extensions", "public"}, - MaxRows: 1000, - } - - remoteConfig := v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public", - DbExtraSearchPath: "public", - MaxRows: 500, - } - - diff, err := api.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) - - t.Run("handles no differences", func(t *testing.T) { - api := &api{ - Enabled: true, - Schemas: []string{"public"}, - ExtraSearchPath: []string{"public"}, - MaxRows: 500, - } - - remoteConfig := v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public", - DbExtraSearchPath: "public", - MaxRows: 500, - } - - diff, err := api.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assert.Empty(t, diff) - }) - - t.Run("handles multiple schemas and search paths with spaces", func(t *testing.T) { - api := &api{ - Enabled: true, - Schemas: []string{"public", "private"}, - ExtraSearchPath: []string{"extensions", "public"}, - MaxRows: 500, - } - - remoteConfig := v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public, private", - DbExtraSearchPath: "extensions, public", - MaxRows: 500, - } - - diff, err := api.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assert.Empty(t, diff) - }) - - t.Run("handles api disabled on remote side", func(t *testing.T) { - api := &api{ - Enabled: true, - Schemas: []string{"public", "private"}, - ExtraSearchPath: []string{"extensions", "public"}, - MaxRows: 500, - } - - remoteConfig := v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "", - DbExtraSearchPath: "", - MaxRows: 0, - } - - diff, err := api.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) - - t.Run("handles api disabled on local side", func(t *testing.T) { - api := &api{ - Enabled: false, - Schemas: []string{"public"}, - ExtraSearchPath: []string{"public"}, - MaxRows: 500, - } - - remoteConfig := v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public", - DbExtraSearchPath: "public", - MaxRows: 500, - } - - diff, err := api.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) -} - -func TestApiAutoExposeNewTablesDefault(t *testing.T) { - t.Run("is unset on a fresh config so the implicit revoke-by-default behaviour applies", func(t *testing.T) { - cfg := NewConfig() - assert.Nil(t, cfg.Api.AutoExposeNewTables) - }) -} - -func TestApiAutoExposeNewTablesWarning(t *testing.T) { - captureStderr := func(t *testing.T, run func()) string { - t.Helper() - r, w, err := os.Pipe() - require.NoError(t, err) - orig := os.Stderr - os.Stderr = w - defer func() { os.Stderr = orig }() - run() - require.NoError(t, w.Close()) - var out bytes.Buffer - _, err = io.Copy(&out, r) - require.NoError(t, err) - return out.String() - } - - t.Run("warns when auto_expose_new_tables is explicitly true", func(t *testing.T) { - cfg := NewConfig() - fsys := fs.MapFS{"config.toml": &fs.MapFile{Data: []byte("[api]\nauto_expose_new_tables = true\n")}} - stderr := captureStderr(t, func() { - require.NoError(t, cfg.Load("config.toml", fsys)) - }) - require.NotNil(t, cfg.Api.AutoExposeNewTables) - assert.True(t, *cfg.Api.AutoExposeNewTables) - assert.Contains(t, stderr, "api.auto_expose_new_tables is deprecated") - assert.Contains(t, stderr, "2026-10-30") - }) - - t.Run("does not warn when unset", func(t *testing.T) { - cfg := NewConfig() - stderr := captureStderr(t, func() { - require.NoError(t, cfg.Load("", fs.MapFS{})) - }) - assert.Nil(t, cfg.Api.AutoExposeNewTables) - assert.NotContains(t, stderr, "auto_expose_new_tables is deprecated") - }) - - t.Run("does not warn when explicitly false", func(t *testing.T) { - cfg := NewConfig() - fsys := fs.MapFS{"config.toml": &fs.MapFile{Data: []byte("[api]\nauto_expose_new_tables = false\n")}} - stderr := captureStderr(t, func() { - require.NoError(t, cfg.Load("config.toml", fsys)) - }) - require.NotNil(t, cfg.Api.AutoExposeNewTables) - assert.False(t, *cfg.Api.AutoExposeNewTables) - assert.NotContains(t, stderr, "auto_expose_new_tables is deprecated") - }) -} diff --git a/apps/cli-go/pkg/config/apikeys.go b/apps/cli-go/pkg/config/apikeys.go deleted file mode 100644 index cb1b457cab..0000000000 --- a/apps/cli-go/pkg/config/apikeys.go +++ /dev/null @@ -1,211 +0,0 @@ -package config - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "encoding/base64" - "math/big" - "time" - - "github.com/go-errors/errors" - "github.com/golang-jwt/jwt/v5" -) - -const ( - defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long" - defaultJwtExpiry = 1983812996 - defaultPublishableKey = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" - defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz" -) - -type CustomClaims struct { - // Overrides Issuer to maintain json order when marshalling - Issuer string `json:"iss,omitempty"` - Ref string `json:"ref,omitempty"` - Role string `json:"role"` - IsAnon bool `json:"is_anonymous,omitempty"` - jwt.RegisteredClaims -} - -func (c CustomClaims) NewToken() *jwt.Token { - if c.ExpiresAt == nil { - c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0)) - } - if len(c.Issuer) == 0 { - c.Issuer = "supabase-demo" - } - return jwt.NewWithClaims(jwt.SigningMethodHS256, c) -} - -// generateAPIKeys generates JWT tokens using the appropriate signing method -func (a *auth) generateAPIKeys() error { - if len(a.JwtSecret.Value) == 0 { - a.JwtSecret.Value = defaultJwtSecret - } else if len(a.JwtSecret.Value) < 16 { - return errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") - } - // Generate anon key if not provided - if len(a.AnonKey.Value) == 0 { - signed, err := a.generateJWT("anon") - if err != nil { - return err - } - a.AnonKey.Value = signed - } - // Generate service_role key if not provided - if len(a.ServiceRoleKey.Value) == 0 { - signed, err := a.generateJWT("service_role") - if err != nil { - return err - } - a.ServiceRoleKey.Value = signed - } - // Set hardcoded opaque keys - if len(a.PublishableKey.Value) == 0 { - a.PublishableKey.Value = defaultPublishableKey - } - if len(a.SecretKey.Value) == 0 { - a.SecretKey.Value = defaultSecretKey - } - return nil -} - -func (a auth) generateJWT(role string) (string, error) { - claims := CustomClaims{Issuer: "supabase-demo", Role: role} - if len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0 { - claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 365 * 10)) // 10 years - return GenerateAsymmetricJWT(a.SigningKeys[0], claims) - } - // Fallback to generating symmetric keys - signed, err := claims.NewToken().SignedString([]byte(a.JwtSecret.Value)) - if err != nil { - return "", errors.Errorf("failed to generate JWT: %w", err) - } - return signed, nil -} - -// GenerateAsymmetricJWT generates a JWT token signed with the provided JWK private key -func GenerateAsymmetricJWT(jwk JWK, claims jwt.Claims) (string, error) { - privateKey, err := jwkToPrivateKey(jwk) - if err != nil { - return "", errors.Errorf("failed to convert JWK to private key: %w", err) - } - - // Determine signing method based on algorithm - var token *jwt.Token - switch jwk.Algorithm { - case AlgRS256: - token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - case AlgES256: - token = jwt.NewWithClaims(jwt.SigningMethodES256, claims) - default: - return "", errors.Errorf("unsupported algorithm: %s", jwk.Algorithm) - } - - if len(jwk.KeyID) > 0 { - token.Header["kid"] = jwk.KeyID - } - - tokenString, err := token.SignedString(privateKey) - if err != nil { - return "", errors.Errorf("failed to sign JWT: %w", err) - } - - return tokenString, nil -} - -// jwkToPrivateKey converts a JWK to a crypto.PrivateKey -func jwkToPrivateKey(jwk JWK) (crypto.PrivateKey, error) { - switch jwk.KeyType { - case "RSA": - return jwkToRSAPrivateKey(jwk) - case "EC": - return jwkToECDSAPrivateKey(jwk) - default: - return nil, errors.Errorf("unsupported key type: %s", jwk.KeyType) - } -} - -// jwkToRSAPrivateKey converts a JWK to an RSA private key -func jwkToRSAPrivateKey(jwk JWK) (*rsa.PrivateKey, error) { - nBytes, err := base64.RawURLEncoding.DecodeString(jwk.Modulus) - if err != nil { - return nil, errors.Errorf("failed to decode modulus: %w", err) - } - n := new(big.Int).SetBytes(nBytes) - - eBytes, err := base64.RawURLEncoding.DecodeString(jwk.Exponent) - if err != nil { - return nil, errors.Errorf("failed to decode exponent: %w", err) - } - e := int(new(big.Int).SetBytes(eBytes).Int64()) - - dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent) - if err != nil { - return nil, errors.Errorf("failed to decode private exponent: %w", err) - } - d := new(big.Int).SetBytes(dBytes) - - pBytes, err := base64.RawURLEncoding.DecodeString(jwk.FirstPrimeFactor) - if err != nil { - return nil, errors.Errorf("failed to decode first prime factor: %w", err) - } - p := new(big.Int).SetBytes(pBytes) - - qBytes, err := base64.RawURLEncoding.DecodeString(jwk.SecondPrimeFactor) - if err != nil { - return nil, errors.Errorf("failed to decode second prime factor: %w", err) - } - q := new(big.Int).SetBytes(qBytes) - - return &rsa.PrivateKey{ - PublicKey: rsa.PublicKey{N: n, E: e}, - D: d, - Primes: []*big.Int{p, q}, - }, nil -} - -// jwkToECDSAPrivateKey converts a JWK to an ECDSA private key -func jwkToECDSAPrivateKey(jwk JWK) (*ecdsa.PrivateKey, error) { - // Only support P-256 curve for ES256 - if jwk.Curve != "P-256" { - return nil, errors.Errorf("unsupported curve: %s", jwk.Curve) - } - - xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) - if err != nil { - return nil, errors.Errorf("failed to decode x coordinate: %w", err) - } - x := new(big.Int).SetBytes(xBytes) - - yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) - if err != nil { - return nil, errors.Errorf("failed to decode y coordinate: %w", err) - } - y := new(big.Int).SetBytes(yBytes) - - dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent) - if err != nil { - return nil, errors.Errorf("failed to decode private key: %w", err) - } - d := new(big.Int).SetBytes(dBytes) - - return &ecdsa.PrivateKey{ - PublicKey: ecdsa.PublicKey{ - Curve: elliptic.P256(), - X: x, - Y: y, - }, - D: d, - }, nil -} - -func NewBigIntFromBase64(n string) (*big.Int, error) { - nBytes, err := base64.RawURLEncoding.DecodeString(n) - if err != nil { - return nil, errors.Errorf("failed to decode base64: %w", err) - } - return new(big.Int).SetBytes(nBytes), nil -} diff --git a/apps/cli-go/pkg/config/auth.go b/apps/cli-go/pkg/config/auth.go deleted file mode 100644 index 75bbf609c3..0000000000 --- a/apps/cli-go/pkg/config/auth.go +++ /dev/null @@ -1,1727 +0,0 @@ -package config - -import ( - "slices" - "strconv" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/oapi-codegen/nullable" - openapi_types "github.com/oapi-codegen/runtime/types" - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/diff" -) - -type PasswordRequirements string - -const ( - NoRequirements PasswordRequirements = "" - LettersDigits PasswordRequirements = "letters_digits" - LowerUpperLettersDigits PasswordRequirements = "lower_upper_letters_digits" - LowerUpperLettersDigitsSymbols PasswordRequirements = "lower_upper_letters_digits_symbols" -) - -func (r *PasswordRequirements) UnmarshalText(text []byte) error { - allowed := []PasswordRequirements{NoRequirements, LettersDigits, LowerUpperLettersDigits, LowerUpperLettersDigitsSymbols} - if *r = PasswordRequirements(text); !slices.Contains(allowed, *r) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -func (r PasswordRequirements) ToChar() v1API.UpdateAuthConfigBodyPasswordRequiredCharacters { - switch r { - case LettersDigits: - return v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 - case LowerUpperLettersDigits: - return v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891 - case LowerUpperLettersDigitsSymbols: - return v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892 - } - return v1API.UpdateAuthConfigBodyPasswordRequiredCharactersEmpty -} - -func NewPasswordRequirement(c v1API.UpdateAuthConfigBodyPasswordRequiredCharacters) PasswordRequirements { - switch c { - case v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789: - return LettersDigits - case v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567891: - return LowerUpperLettersDigits - case v1API.UpdateAuthConfigBodyPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567892: - return LowerUpperLettersDigitsSymbols - } - return NoRequirements -} - -type CaptchaProvider string - -const ( - HCaptchaProvider CaptchaProvider = "hcaptcha" - TurnstileProvider CaptchaProvider = "turnstile" -) - -func (p *CaptchaProvider) UnmarshalText(text []byte) error { - allowed := []CaptchaProvider{HCaptchaProvider, TurnstileProvider} - if *p = CaptchaProvider(text); !slices.Contains(allowed, *p) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type Algorithm string - -const ( - AlgRS256 Algorithm = "RS256" - AlgES256 Algorithm = "ES256" -) - -func (p *Algorithm) UnmarshalText(text []byte) error { - allowed := []Algorithm{AlgRS256, AlgES256} - if *p = Algorithm(text); !slices.Contains(allowed, *p) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type JWK struct { - KeyType string `toml:"kty" json:"kty"` - KeyID string `toml:"kid,omitempty" json:"kid,omitempty"` - Use string `toml:"use,omitempty" json:"use,omitempty"` - KeyOps []string `toml:"key_ops,omitempty" json:"key_ops,omitempty"` - Algorithm Algorithm `toml:"alg,omitempty" json:"alg,omitempty"` - Extractable *bool `toml:"ext,omitempty" json:"ext,omitempty"` - // RSA specific fields - Modulus string `toml:"n,omitempty" json:"n,omitempty"` - Exponent string `toml:"e,omitempty" json:"e,omitempty"` - // RSA private key fields - PrivateExponent string `toml:"d,omitempty" json:"d,omitempty"` - FirstPrimeFactor string `toml:"p,omitempty" json:"p,omitempty"` - SecondPrimeFactor string `toml:"q,omitempty" json:"q,omitempty"` - FirstFactorCRTExponent string `toml:"dp,omitempty" json:"dp,omitempty"` - SecondFactorCRTExponent string `toml:"dq,omitempty" json:"dq,omitempty"` - FirstCRTCoefficient string `toml:"qi,omitempty" json:"qi,omitempty"` - // EC specific fields - Curve string `toml:"crv,omitempty" json:"crv,omitempty"` - X string `toml:"x,omitempty" json:"x,omitempty"` - Y string `toml:"y,omitempty" json:"y,omitempty"` -} - -// ToPublicJWK converts a JWK to a public-only version by removing private key components -func (j JWK) ToPublicJWK() JWK { - publicJWK := JWK{ - KeyType: j.KeyType, - KeyID: j.KeyID, - Use: j.Use, - Algorithm: j.Algorithm, - } - - // Copy the underlying type instead of the pointer - if j.Extractable != nil { - publicJWK.Extractable = cast.Ptr(*j.Extractable) - } - - // Only include key_ops for verification (not signing) for public keys - for _, op := range j.KeyOps { - if op == "verify" { - publicJWK.KeyOps = append(publicJWK.KeyOps, op) - } - } - - switch j.KeyType { - case "RSA": - // Include only public key components for RSA - publicJWK.Modulus = j.Modulus - publicJWK.Exponent = j.Exponent - case "EC": - // Include only public key components for ECDSA - publicJWK.Curve = j.Curve - publicJWK.X = j.X - publicJWK.Y = j.Y - } - - return publicJWK -} - -type ( - auth struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - - SiteUrl string `toml:"site_url" json:"site_url"` - ExternalUrl string `toml:"external_url" json:"external_url"` - AdditionalRedirectUrls []string `toml:"additional_redirect_urls" json:"additional_redirect_urls"` - JwtExpiry uint `toml:"jwt_expiry" json:"jwt_expiry"` - JwtIssuer string `toml:"jwt_issuer" json:"jwt_issuer"` - EnableRefreshTokenRotation bool `toml:"enable_refresh_token_rotation" json:"enable_refresh_token_rotation"` - RefreshTokenReuseInterval uint `toml:"refresh_token_reuse_interval" json:"refresh_token_reuse_interval"` - EnableManualLinking bool `toml:"enable_manual_linking" json:"enable_manual_linking"` - EnableSignup bool `toml:"enable_signup" json:"enable_signup"` - EnableAnonymousSignIns bool `toml:"enable_anonymous_sign_ins" json:"enable_anonymous_sign_ins"` - MinimumPasswordLength uint `toml:"minimum_password_length" json:"minimum_password_length"` - PasswordRequirements PasswordRequirements `toml:"password_requirements" json:"password_requirements"` - SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"` - SigningKeys []JWK `toml:"-" json:"-"` - Passkey *Passkey `toml:"passkey" json:"passkey"` - Webauthn *Webauthn `toml:"webauthn" json:"webauthn"` - - RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"` - Captcha *captcha `toml:"captcha" json:"captcha"` - Hook hook `toml:"hook" json:"hook"` - MFA mfa `toml:"mfa" json:"mfa"` - Sessions sessions `toml:"sessions" json:"sessions"` - Email email `toml:"email" json:"email"` - Sms sms `toml:"sms" json:"sms"` - External external `toml:"external" json:"external"` - Web3 web3 `toml:"web3" json:"web3"` - OAuthServer OAuthServer `toml:"oauth_server" json:"oauth_server"` - - // Custom secrets can be injected from .env file - PublishableKey Secret `toml:"publishable_key" json:"publishable_key"` - SecretKey Secret `toml:"secret_key" json:"secret_key"` - JwtSecret Secret `toml:"jwt_secret" json:"jwt_secret"` - AnonKey Secret `toml:"anon_key" json:"anon_key"` - ServiceRoleKey Secret `toml:"service_role_key" json:"service_role_key"` - - ThirdParty thirdParty `toml:"third_party" json:"third_party"` - } - - external map[string]provider - - thirdParty struct { - Firebase tpaFirebase `toml:"firebase" json:"firebase"` - Auth0 tpaAuth0 `toml:"auth0" json:"auth0"` - Cognito tpaCognito `toml:"aws_cognito" json:"aws_cognito"` - Clerk tpaClerk `toml:"clerk" json:"clerk"` - WorkOs tpaWorkOs `toml:"workos" json:"workos"` - } - - rateLimit struct { - AnonymousUsers uint `toml:"anonymous_users" json:"anonymous_users"` - TokenRefresh uint `toml:"token_refresh" json:"token_refresh"` - SignInSignUps uint `toml:"sign_in_sign_ups" json:"sign_in_sign_ups"` - TokenVerifications uint `toml:"token_verifications" json:"token_verifications"` - EmailSent uint `toml:"email_sent" json:"email_sent"` - SmsSent uint `toml:"sms_sent" json:"sms_sent"` - Web3 uint `toml:"web3" json:"web3"` - } - - tpaFirebase struct { - Enabled bool `toml:"enabled" json:"enabled"` - - ProjectID string `toml:"project_id" json:"project_id"` - } - - tpaAuth0 struct { - Enabled bool `toml:"enabled" json:"enabled"` - - Tenant string `toml:"tenant" json:"tenant"` - TenantRegion string `toml:"tenant_region" json:"tenant_region"` - } - - tpaCognito struct { - Enabled bool `toml:"enabled" json:"enabled"` - - UserPoolID string `toml:"user_pool_id" json:"user_pool_id"` - UserPoolRegion string `toml:"user_pool_region" json:"user_pool_region"` - } - - tpaClerk struct { - Enabled bool `toml:"enabled" json:"enabled"` - - Domain string `toml:"domain" json:"domain"` - } - - tpaWorkOs struct { - Enabled bool `toml:"enabled" json:"enabled"` - - IssuerUrl string `toml:"issuer_url" json:"issuer_url"` - } - - email struct { - EnableSignup bool `toml:"enable_signup" json:"enable_signup"` - DoubleConfirmChanges bool `toml:"double_confirm_changes" json:"double_confirm_changes"` - EnableConfirmations bool `toml:"enable_confirmations" json:"enable_confirmations"` - SecurePasswordChange bool `toml:"secure_password_change" json:"secure_password_change"` - Template map[string]emailTemplate `toml:"template" json:"template"` - Notification map[string]notification `toml:"notification" json:"notification"` - Smtp *smtp `toml:"smtp" json:"smtp"` - MaxFrequency time.Duration `toml:"max_frequency" json:"max_frequency"` - OtpLength uint `toml:"otp_length" json:"otp_length"` - OtpExpiry uint `toml:"otp_expiry" json:"otp_expiry"` - } - - smtp struct { - Enabled bool `toml:"enabled" json:"enabled"` - Host string `toml:"host" json:"host"` - Port uint16 `toml:"port" json:"port"` - User string `toml:"user" json:"user"` - Pass Secret `toml:"pass" json:"pass"` - AdminEmail openapi_types.Email `toml:"admin_email" json:"admin_email"` - SenderName string `toml:"sender_name" json:"sender_name"` - } - - emailTemplate struct { - Subject *string `toml:"subject" json:"subject"` - Content *string `toml:"content" json:"content"` - // Only content path is accepted in config.toml - ContentPath string `toml:"content_path" json:"content_path"` - } - - notification struct { - Enabled bool `toml:"enabled" json:"enabled"` - emailTemplate - } - - sms struct { - EnableSignup bool `toml:"enable_signup" json:"enable_signup"` - EnableConfirmations bool `toml:"enable_confirmations" json:"enable_confirmations"` - Template string `toml:"template" json:"template"` - Twilio twilioConfig `toml:"twilio" json:"twilio"` - TwilioVerify twilioConfig `toml:"twilio_verify" json:"twilio_verify"` - Messagebird messagebirdConfig `toml:"messagebird" json:"messagebird"` - Textlocal textlocalConfig `toml:"textlocal" json:"textlocal"` - Vonage vonageConfig `toml:"vonage" json:"vonage"` - TestOTP map[string]string `toml:"test_otp" json:"test_otp"` - MaxFrequency time.Duration `toml:"max_frequency" json:"max_frequency"` - } - - captcha struct { - Enabled bool `toml:"enabled" json:"enabled"` - Provider CaptchaProvider `toml:"provider" json:"provider"` - Secret Secret `toml:"secret" json:"secret"` - } - - hook struct { - MFAVerificationAttempt *hookConfig `toml:"mfa_verification_attempt" json:"mfa_verification_attempt"` - PasswordVerificationAttempt *hookConfig `toml:"password_verification_attempt" json:"password_verification_attempt"` - CustomAccessToken *hookConfig `toml:"custom_access_token" json:"custom_access_token"` - SendSMS *hookConfig `toml:"send_sms" json:"send_sms"` - SendEmail *hookConfig `toml:"send_email" json:"send_email"` - BeforeUserCreated *hookConfig `toml:"before_user_created" json:"before_user_created"` - } - - factorTypeConfiguration struct { - EnrollEnabled bool `toml:"enroll_enabled" json:"enroll_enabled"` - VerifyEnabled bool `toml:"verify_enabled" json:"verify_enabled"` - } - - phoneFactorTypeConfiguration struct { - factorTypeConfiguration - OtpLength uint `toml:"otp_length" json:"otp_length"` - Template string `toml:"template" json:"template"` - MaxFrequency time.Duration `toml:"max_frequency" json:"max_frequency"` - } - - mfa struct { - TOTP factorTypeConfiguration `toml:"totp" json:"totp"` - Phone phoneFactorTypeConfiguration `toml:"phone" json:"phone"` - WebAuthn factorTypeConfiguration `toml:"web_authn" json:"web_authn"` - MaxEnrolledFactors uint `toml:"max_enrolled_factors" json:"max_enrolled_factors"` - } - - hookConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - URI string `toml:"uri" json:"uri"` - Secrets Secret `toml:"secrets" json:"secrets"` - } - - sessions struct { - Timebox time.Duration `toml:"timebox" json:"timebox"` - InactivityTimeout time.Duration `toml:"inactivity_timeout" json:"inactivity_timeout"` - } - - twilioConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - AccountSid string `toml:"account_sid" json:"account_sid"` - MessageServiceSid string `toml:"message_service_sid" json:"message_service_sid"` - AuthToken Secret `toml:"auth_token" json:"auth_token"` - } - - messagebirdConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - Originator string `toml:"originator" json:"originator"` - AccessKey Secret `toml:"access_key" json:"access_key"` - } - - textlocalConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - Sender string `toml:"sender" json:"sender"` - ApiKey Secret `toml:"api_key" json:"api_key"` - } - - vonageConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - From string `toml:"from" json:"from"` - ApiKey string `toml:"api_key" json:"api_key"` - ApiSecret Secret `toml:"api_secret" json:"api_secret"` - } - - provider struct { - Enabled bool `toml:"enabled" json:"enabled"` - ClientId string `toml:"client_id" json:"client_id"` - Secret Secret `toml:"secret" json:"secret"` - Url string `toml:"url" json:"url"` - RedirectUri string `toml:"redirect_uri" json:"redirect_uri"` - SkipNonceCheck bool `toml:"skip_nonce_check" json:"skip_nonce_check"` - EmailOptional bool `toml:"email_optional" json:"email_optional"` - } - - solana struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - ethereum struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - web3 struct { - Solana solana `toml:"solana" json:"solana"` - Ethereum ethereum `toml:"ethereum" json:"ethereum"` - } - - Passkey struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - Webauthn struct { - RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"` - RpId string `toml:"rp_id" json:"rp_id"` - RpOrigins []string `toml:"rp_origins" json:"rp_origins"` - } - - OAuthServer struct { - Enabled bool `toml:"enabled" json:"enabled"` - AllowDynamicRegistration bool `toml:"allow_dynamic_registration" json:"allow_dynamic_registration"` - AuthorizationUrlPath string `toml:"authorization_url_path" json:"authorization_url_path"` - } -) - -func (a auth) GetExternalURL(apiExternalURL string) string { - if len(a.ExternalUrl) > 0 { - return a.ExternalUrl - } - return strings.TrimRight(apiExternalURL, "/") + "/auth/v1" -} - -func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { - body := v1API.UpdateAuthConfigBody{ - SiteUrl: nullable.NewNullableWithValue(a.SiteUrl), - UriAllowList: nullable.NewNullableWithValue(strings.Join(a.AdditionalRedirectUrls, ",")), - JwtExp: nullable.NewNullableWithValue(cast.UintToInt(a.JwtExpiry)), - RefreshTokenRotationEnabled: nullable.NewNullableWithValue(a.EnableRefreshTokenRotation), - SecurityRefreshTokenReuseInterval: nullable.NewNullableWithValue(cast.UintToInt(a.RefreshTokenReuseInterval)), - SecurityManualLinkingEnabled: nullable.NewNullableWithValue(a.EnableManualLinking), - DisableSignup: nullable.NewNullableWithValue(!a.EnableSignup), - ExternalAnonymousUsersEnabled: nullable.NewNullableWithValue(a.EnableAnonymousSignIns), - PasswordMinLength: nullable.NewNullableWithValue(cast.UintToInt(a.MinimumPasswordLength)), - PasswordRequiredCharacters: nullable.NewNullableWithValue(a.PasswordRequirements.ToChar()), - } - // Add rate limit fields - a.RateLimit.toAuthConfigBody(&body) - if s := a.Email.Smtp; s != nil && s.Enabled { - body.RateLimitEmailSent = nullable.NewNullableWithValue(cast.UintToInt(a.RateLimit.EmailSent)) - } - // When local config is not set, we assume platform defaults should not change - if a.Captcha != nil { - a.Captcha.toAuthConfigBody(&body) - } - if a.Passkey != nil { - a.Passkey.toAuthConfigBody(&body) - } - if a.Webauthn != nil { - a.Webauthn.toAuthConfigBody(&body) - } - a.Hook.toAuthConfigBody(&body) - a.MFA.toAuthConfigBody(&body) - a.Sessions.toAuthConfigBody(&body) - a.Email.toAuthConfigBody(&body) - a.Sms.toAuthConfigBody(&body) - a.External.toAuthConfigBody(&body) - a.Web3.toAuthConfigBody(&body) - a.OAuthServer.toAuthConfigBody(&body) - return body -} - -func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - a.SiteUrl = ValOrDefault(remoteConfig.SiteUrl, "") - a.AdditionalRedirectUrls = strToArr(ValOrDefault(remoteConfig.UriAllowList, "")) - a.JwtExpiry = cast.IntToUint(ValOrDefault(remoteConfig.JwtExp, 0)) - a.EnableRefreshTokenRotation = ValOrDefault(remoteConfig.RefreshTokenRotationEnabled, false) - a.RefreshTokenReuseInterval = cast.IntToUint(ValOrDefault(remoteConfig.SecurityRefreshTokenReuseInterval, 0)) - a.EnableManualLinking = ValOrDefault(remoteConfig.SecurityManualLinkingEnabled, false) - a.EnableSignup = !ValOrDefault(remoteConfig.DisableSignup, false) - a.EnableAnonymousSignIns = ValOrDefault(remoteConfig.ExternalAnonymousUsersEnabled, false) - a.MinimumPasswordLength = cast.IntToUint(ValOrDefault(remoteConfig.PasswordMinLength, 0)) - prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "") - a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc)) - a.Passkey.fromAuthConfig(remoteConfig) - a.Webauthn.fromAuthConfig(remoteConfig) - a.RateLimit.fromAuthConfig(remoteConfig) - if s := a.Email.Smtp; s != nil && s.Enabled { - a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0)) - } - a.Captcha.fromAuthConfig(remoteConfig) - a.Hook.fromAuthConfig(remoteConfig) - a.MFA.fromAuthConfig(remoteConfig) - a.Sessions.fromAuthConfig(remoteConfig) - a.Email.fromAuthConfig(remoteConfig) - a.Sms.fromAuthConfig(remoteConfig) - a.External.fromAuthConfig(remoteConfig) - a.Web3.fromAuthConfig(remoteConfig) - a.OAuthServer.fromAuthConfig(remoteConfig) -} - -func (r rateLimit) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.RateLimitAnonymousUsers = nullable.NewNullableWithValue(cast.UintToInt(r.AnonymousUsers)) - body.RateLimitTokenRefresh = nullable.NewNullableWithValue(cast.UintToInt(r.TokenRefresh)) - body.RateLimitOtp = nullable.NewNullableWithValue(cast.UintToInt(r.SignInSignUps)) - body.RateLimitVerify = nullable.NewNullableWithValue(cast.UintToInt(r.TokenVerifications)) - // Email rate limit is only updated when SMTP is enabled - body.RateLimitSmsSent = nullable.NewNullableWithValue(cast.UintToInt(r.SmsSent)) - body.RateLimitWeb3 = nullable.NewNullableWithValue((cast.UintToInt(r.Web3))) -} - -func (r *rateLimit) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - r.AnonymousUsers = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitAnonymousUsers, 0)) - r.TokenRefresh = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitTokenRefresh, 0)) - r.SignInSignUps = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitOtp, 0)) - r.TokenVerifications = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitVerify, 0)) - // Email rate limit is only updated when SMTP is enabled - r.SmsSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitSmsSent, 0)) - r.Web3 = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitWeb3, 0)) -} - -func (c captcha) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - if body.SecurityCaptchaEnabled = nullable.NewNullableWithValue(c.Enabled); c.Enabled { - body.SecurityCaptchaProvider = nullable.NewNullableWithValue(v1API.UpdateAuthConfigBodySecurityCaptchaProvider(c.Provider)) - if len(c.Secret.SHA256) > 0 { - body.SecurityCaptchaSecret = nullable.NewNullableWithValue(c.Secret.Value) - } - } -} - -func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // When local config is not set, we assume platform defaults should not change - if c == nil { - return - } - // Ignore disabled captcha fields to minimise config diff - if c.Enabled { - c.Provider = CaptchaProvider(ValOrDefault(remoteConfig.SecurityCaptchaProvider, "")) - if len(c.Secret.SHA256) > 0 { - c.Secret.SHA256 = ValOrDefault(remoteConfig.SecurityCaptchaSecret, "") - } - } - c.Enabled = ValOrDefault(remoteConfig.SecurityCaptchaEnabled, false) -} - -func (p Passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.PasskeyEnabled = cast.Ptr(p.Enabled) -} - -func (p *Passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // When local config is not set, we assume platform defaults should not change - if p == nil { - return - } - p.Enabled = remoteConfig.PasskeyEnabled -} - -func (w Webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.WebauthnRpDisplayName = nullable.NewNullableWithValue(w.RpDisplayName) - body.WebauthnRpId = nullable.NewNullableWithValue(w.RpId) - body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(w.RpOrigins, ",")) -} - -func (w *Webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // When local config is not set, we assume platform defaults should not change - if w == nil { - return - } - w.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "") - w.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "") - w.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, "")) -} - -func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - // When local config is not set, we assume platform defaults should not change - if hook := h.BeforeUserCreated; hook != nil { - if body.HookBeforeUserCreatedEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookBeforeUserCreatedUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookBeforeUserCreatedSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } - if hook := h.CustomAccessToken; hook != nil { - if body.HookCustomAccessTokenEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookCustomAccessTokenUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookCustomAccessTokenSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } - if hook := h.SendEmail; hook != nil { - if body.HookSendEmailEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookSendEmailUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookSendEmailSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } - if hook := h.SendSMS; hook != nil { - if body.HookSendSmsEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookSendSmsUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookSendSmsSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } - // Enterprise and team only features - if hook := h.MFAVerificationAttempt; hook != nil { - if body.HookMfaVerificationAttemptEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookMfaVerificationAttemptUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookMfaVerificationAttemptSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } - if hook := h.PasswordVerificationAttempt; hook != nil { - if body.HookPasswordVerificationAttemptEnabled = nullable.NewNullableWithValue(hook.Enabled); hook.Enabled { - body.HookPasswordVerificationAttemptUri = nullable.NewNullableWithValue(hook.URI) - if len(hook.Secrets.SHA256) > 0 { - body.HookPasswordVerificationAttemptSecrets = nullable.NewNullableWithValue(hook.Secrets.Value) - } - } - } -} -func (h *hook) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // When local config is not set, we assume platform defaults should not change - if hook := h.BeforeUserCreated; hook != nil { - // Ignore disabled hooks because their envs are not loaded - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookBeforeUserCreatedUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookBeforeUserCreatedSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookBeforeUserCreatedEnabled, false) - } - if hook := h.CustomAccessToken; hook != nil { - // Ignore disabled hooks because their envs are not loaded - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookCustomAccessTokenUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookCustomAccessTokenSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookCustomAccessTokenEnabled, false) - } - if hook := h.SendEmail; hook != nil { - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookSendEmailUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookSendEmailSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookSendEmailEnabled, false) - } - if hook := h.SendSMS; hook != nil { - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookSendSmsUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookSendSmsSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookSendSmsEnabled, false) - } - // Enterprise and team only features - if hook := h.MFAVerificationAttempt; hook != nil { - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookMfaVerificationAttemptUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookMfaVerificationAttemptSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookMfaVerificationAttemptEnabled, false) - } - if hook := h.PasswordVerificationAttempt; hook != nil { - if hook.Enabled { - hook.URI = ValOrDefault(remoteConfig.HookPasswordVerificationAttemptUri, "") - if len(hook.Secrets.SHA256) > 0 { - hook.Secrets.SHA256 = ValOrDefault(remoteConfig.HookPasswordVerificationAttemptSecrets, "") - } - } - hook.Enabled = ValOrDefault(remoteConfig.HookPasswordVerificationAttemptEnabled, false) - } -} - -func (m mfa) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.MfaMaxEnrolledFactors = nullable.NewNullableWithValue(cast.UintToInt(m.MaxEnrolledFactors)) - body.MfaTotpEnrollEnabled = nullable.NewNullableWithValue(m.TOTP.EnrollEnabled) - body.MfaTotpVerifyEnabled = nullable.NewNullableWithValue(m.TOTP.VerifyEnabled) - body.MfaPhoneEnrollEnabled = nullable.NewNullableWithValue(m.Phone.EnrollEnabled) - body.MfaPhoneVerifyEnabled = nullable.NewNullableWithValue(m.Phone.VerifyEnabled) - body.MfaPhoneOtpLength = nullable.NewNullableWithValue(cast.UintToInt(m.Phone.OtpLength)) - body.MfaPhoneTemplate = nullable.NewNullableWithValue(m.Phone.Template) - body.MfaPhoneMaxFrequency = nullable.NewNullableWithValue(int(m.Phone.MaxFrequency.Seconds())) - body.MfaWebAuthnEnrollEnabled = nullable.NewNullableWithValue(m.WebAuthn.EnrollEnabled) - body.MfaWebAuthnVerifyEnabled = nullable.NewNullableWithValue(m.WebAuthn.VerifyEnabled) -} - -func (m *mfa) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - m.MaxEnrolledFactors = cast.IntToUint(ValOrDefault(remoteConfig.MfaMaxEnrolledFactors, 0)) - m.TOTP.EnrollEnabled = ValOrDefault(remoteConfig.MfaTotpEnrollEnabled, false) - m.TOTP.VerifyEnabled = ValOrDefault(remoteConfig.MfaTotpVerifyEnabled, false) - m.Phone.EnrollEnabled = ValOrDefault(remoteConfig.MfaPhoneEnrollEnabled, false) - m.Phone.VerifyEnabled = ValOrDefault(remoteConfig.MfaPhoneVerifyEnabled, false) - m.Phone.OtpLength = cast.IntToUint(remoteConfig.MfaPhoneOtpLength) - m.Phone.Template = ValOrDefault(remoteConfig.MfaPhoneTemplate, "") - m.Phone.MaxFrequency = time.Duration(ValOrDefault(remoteConfig.MfaPhoneMaxFrequency, 0)) * time.Second - m.WebAuthn.EnrollEnabled = ValOrDefault(remoteConfig.MfaWebAuthnEnrollEnabled, false) - m.WebAuthn.VerifyEnabled = ValOrDefault(remoteConfig.MfaWebAuthnVerifyEnabled, false) -} - -func (s sessions) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.SessionsTimebox = nullable.NewNullableWithValue(float32(s.Timebox.Hours())) - body.SessionsInactivityTimeout = nullable.NewNullableWithValue(float32(s.InactivityTimeout.Hours())) -} - -func (s *sessions) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - s.Timebox = time.Duration(ValOrDefault(remoteConfig.SessionsTimebox, 0)) * time.Hour - s.InactivityTimeout = time.Duration(ValOrDefault(remoteConfig.SessionsInactivityTimeout, 0)) * time.Hour -} - -func (e email) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.ExternalEmailEnabled = nullable.NewNullableWithValue(e.EnableSignup) - body.MailerSecureEmailChangeEnabled = nullable.NewNullableWithValue(e.DoubleConfirmChanges) - body.MailerAutoconfirm = nullable.NewNullableWithValue(!e.EnableConfirmations) - body.MailerOtpLength = nullable.NewNullableWithValue(cast.UintToInt(e.OtpLength)) - body.MailerOtpExp = cast.UintToIntPtr(&e.OtpExpiry) - body.SecurityUpdatePasswordRequireReauthentication = nullable.NewNullableWithValue(e.SecurePasswordChange) - body.SmtpMaxFrequency = nullable.NewNullableWithValue(int(e.MaxFrequency.Seconds())) - // When local config is not set, we assume platform defaults should not change - if e.Smtp != nil { - e.Smtp.toAuthConfigBody(body) - } - if len(e.Template) > 0 { - var tmpl *emailTemplate - tmpl = cast.Ptr(e.Template["invite"]) - if tmpl.Subject != nil { - body.MailerSubjectsInvite = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesInviteContent = nullable.NewNullableWithValue(*tmpl.Content) - } - tmpl = cast.Ptr(e.Template["confirmation"]) - if tmpl.Subject != nil { - body.MailerSubjectsConfirmation = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesConfirmationContent = nullable.NewNullableWithValue(*tmpl.Content) - } - tmpl = cast.Ptr(e.Template["recovery"]) - if tmpl.Subject != nil { - body.MailerSubjectsRecovery = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesRecoveryContent = nullable.NewNullableWithValue(*tmpl.Content) - } - tmpl = cast.Ptr(e.Template["magic_link"]) - if tmpl.Subject != nil { - body.MailerSubjectsMagicLink = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesMagicLinkContent = nullable.NewNullableWithValue(*tmpl.Content) - } - tmpl = cast.Ptr(e.Template["email_change"]) - if tmpl.Subject != nil { - body.MailerSubjectsEmailChange = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesEmailChangeContent = nullable.NewNullableWithValue(*tmpl.Content) - } - tmpl = cast.Ptr(e.Template["reauthentication"]) - if tmpl.Subject != nil { - body.MailerSubjectsReauthentication = nullable.NewNullableWithValue(*tmpl.Subject) - } - if tmpl.Content != nil { - body.MailerTemplatesReauthenticationContent = nullable.NewNullableWithValue(*tmpl.Content) - } - } - // Notifications - if len(e.Notification) > 0 { - if n, ok := e.Notification["password_changed"]; ok { - body.MailerNotificationsPasswordChangedEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsPasswordChangedNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesPasswordChangedNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["email_changed"]; ok { - body.MailerNotificationsEmailChangedEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsEmailChangedNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesEmailChangedNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["phone_changed"]; ok { - body.MailerNotificationsPhoneChangedEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsPhoneChangedNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesPhoneChangedNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["identity_linked"]; ok { - body.MailerNotificationsIdentityLinkedEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsIdentityLinkedNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesIdentityLinkedNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["identity_unlinked"]; ok { - body.MailerNotificationsIdentityUnlinkedEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsIdentityUnlinkedNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesIdentityUnlinkedNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["mfa_factor_enrolled"]; ok { - body.MailerNotificationsMfaFactorEnrolledEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsMfaFactorEnrolledNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesMfaFactorEnrolledNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - if n, ok := e.Notification["mfa_factor_unenrolled"]; ok { - body.MailerNotificationsMfaFactorUnenrolledEnabled = nullable.NewNullableWithValue(n.Enabled) - if n.Subject != nil { - body.MailerSubjectsMfaFactorUnenrolledNotification = nullable.NewNullableWithValue(*n.Subject) - } - if n.Content != nil { - body.MailerTemplatesMfaFactorUnenrolledNotificationContent = nullable.NewNullableWithValue(*n.Content) - } - } - } -} - -func (e *email) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - e.EnableSignup = ValOrDefault(remoteConfig.ExternalEmailEnabled, false) - e.DoubleConfirmChanges = ValOrDefault(remoteConfig.MailerSecureEmailChangeEnabled, false) - e.EnableConfirmations = !ValOrDefault(remoteConfig.MailerAutoconfirm, false) - e.OtpLength = cast.IntToUint(ValOrDefault(remoteConfig.MailerOtpLength, 0)) - e.OtpExpiry = cast.IntToUint(remoteConfig.MailerOtpExp) - e.SecurePasswordChange = ValOrDefault(remoteConfig.SecurityUpdatePasswordRequireReauthentication, false) - e.MaxFrequency = time.Duration(ValOrDefault(remoteConfig.SmtpMaxFrequency, 0)) * time.Second - e.Smtp.fromAuthConfig(remoteConfig) - if len(e.Template) > 0 { - if t, ok := e.Template["invite"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsInvite.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesInviteContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["invite"] = t - } - if t, ok := e.Template["confirmation"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsConfirmation.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesConfirmationContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["confirmation"] = t - } - if t, ok := e.Template["recovery"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsRecovery.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesRecoveryContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["recovery"] = t - } - if t, ok := e.Template["magic_link"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsMagicLink.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesMagicLinkContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["magic_link"] = t - } - if t, ok := e.Template["email_change"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsEmailChange.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesEmailChangeContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["email_change"] = t - } - if t, ok := e.Template["reauthentication"]; ok { - if t.Subject != nil { - if value, err := remoteConfig.MailerSubjectsReauthentication.Get(); err == nil { - t.Subject = &value - } else { - t.Subject = nil - } - } - if t.Content != nil { - if value, err := remoteConfig.MailerTemplatesReauthenticationContent.Get(); err == nil { - t.Content = &value - } else { - t.Content = nil - } - } - e.Template["reauthentication"] = t - } - } - // Notifications - if len(e.Notification) > 0 { - if n, ok := e.Notification["password_changed"]; ok { - if value, err := remoteConfig.MailerNotificationsPasswordChangedEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsPasswordChangedNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesPasswordChangedNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["password_changed"] = n - } - if n, ok := e.Notification["email_changed"]; ok { - if value, err := remoteConfig.MailerNotificationsEmailChangedEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsEmailChangedNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesEmailChangedNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["email_changed"] = n - } - if n, ok := e.Notification["phone_changed"]; ok { - if value, err := remoteConfig.MailerNotificationsPhoneChangedEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsPhoneChangedNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesPhoneChangedNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["phone_changed"] = n - } - if n, ok := e.Notification["identity_linked"]; ok { - if value, err := remoteConfig.MailerNotificationsIdentityLinkedEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsIdentityLinkedNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesIdentityLinkedNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["identity_linked"] = n - } - if n, ok := e.Notification["identity_unlinked"]; ok { - if value, err := remoteConfig.MailerNotificationsIdentityUnlinkedEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsIdentityUnlinkedNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesIdentityUnlinkedNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["identity_unlinked"] = n - } - if n, ok := e.Notification["mfa_factor_enrolled"]; ok { - if value, err := remoteConfig.MailerNotificationsMfaFactorEnrolledEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsMfaFactorEnrolledNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesMfaFactorEnrolledNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["mfa_factor_enrolled"] = n - } - if n, ok := e.Notification["mfa_factor_unenrolled"]; ok { - if value, err := remoteConfig.MailerNotificationsMfaFactorUnenrolledEnabled.Get(); err == nil { - n.Enabled = value - } - if n.Subject != nil { - if value, err := remoteConfig.MailerSubjectsMfaFactorUnenrolledNotification.Get(); err == nil { - n.Subject = &value - } else { - n.Subject = nil - } - } - if n.Content != nil { - if value, err := remoteConfig.MailerTemplatesMfaFactorUnenrolledNotificationContent.Get(); err == nil { - n.Content = &value - } else { - n.Content = nil - } - } - e.Notification["mfa_factor_unenrolled"] = n - } - } -} - -func (s smtp) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - if !s.Enabled { - // Setting a single empty string disables SMTP - body.SmtpHost = nullable.NewNullableWithValue("") - return - } - body.SmtpHost = nullable.NewNullableWithValue(s.Host) - body.SmtpPort = nullable.NewNullableWithValue(strconv.Itoa(int(s.Port))) - body.SmtpUser = nullable.NewNullableWithValue(s.User) - if len(s.Pass.SHA256) > 0 { - body.SmtpPass = nullable.NewNullableWithValue(s.Pass.Value) - } - body.SmtpAdminEmail = nullable.NewNullableWithValue(s.AdminEmail) - body.SmtpSenderName = nullable.NewNullableWithValue(s.SenderName) -} - -func (s *smtp) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // When local config is not set, we assume platform defaults should not change - if s == nil { - return - } - if s.Enabled { - s.Host = ValOrDefault(remoteConfig.SmtpHost, "") - s.User = ValOrDefault(remoteConfig.SmtpUser, "") - if len(s.Pass.SHA256) > 0 { - s.Pass.SHA256 = ValOrDefault(remoteConfig.SmtpPass, "") - } - s.AdminEmail = ValOrDefault(remoteConfig.SmtpAdminEmail, openapi_types.Email("")) - s.SenderName = ValOrDefault(remoteConfig.SmtpSenderName, "") - portStr := ValOrDefault(remoteConfig.SmtpPort, "0") - if port, err := strconv.ParseUint(portStr, 10, 16); err == nil { - s.Port = uint16(port) - } - } - // Api resets all values when SMTP is disabled - s.Enabled = remoteConfig.SmtpHost != nil -} - -func (s sms) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.ExternalPhoneEnabled = nullable.NewNullableWithValue(s.EnableSignup) - body.SmsMaxFrequency = nullable.NewNullableWithValue(int(s.MaxFrequency.Seconds())) - body.SmsAutoconfirm = nullable.NewNullableWithValue(s.EnableConfirmations) - body.SmsTemplate = nullable.NewNullableWithValue(s.Template) - if otpString := mapToEnv(s.TestOTP); len(otpString) > 0 { - body.SmsTestOtp = nullable.NewNullableWithValue(otpString) - // Set a 10 year validity for test OTP - timestamp := time.Now().UTC().AddDate(10, 0, 0) - body.SmsTestOtpValidUntil = nullable.NewNullableWithValue(timestamp) - } - // Api only overrides configs of enabled providers - switch { - case s.Twilio.Enabled: - body.SmsProvider = nullable.NewNullableWithValue(v1API.Twilio) - if len(s.Twilio.AuthToken.SHA256) > 0 { - body.SmsTwilioAuthToken = nullable.NewNullableWithValue(s.Twilio.AuthToken.Value) - } - body.SmsTwilioAccountSid = nullable.NewNullableWithValue(s.Twilio.AccountSid) - body.SmsTwilioMessageServiceSid = nullable.NewNullableWithValue(s.Twilio.MessageServiceSid) - case s.TwilioVerify.Enabled: - body.SmsProvider = nullable.NewNullableWithValue(v1API.TwilioVerify) - if len(s.TwilioVerify.AuthToken.SHA256) > 0 { - body.SmsTwilioVerifyAuthToken = nullable.NewNullableWithValue(s.TwilioVerify.AuthToken.Value) - } - body.SmsTwilioVerifyAccountSid = nullable.NewNullableWithValue(s.TwilioVerify.AccountSid) - body.SmsTwilioVerifyMessageServiceSid = nullable.NewNullableWithValue(s.TwilioVerify.MessageServiceSid) - case s.Messagebird.Enabled: - body.SmsProvider = nullable.NewNullableWithValue(v1API.Messagebird) - if len(s.Messagebird.AccessKey.SHA256) > 0 { - body.SmsMessagebirdAccessKey = nullable.NewNullableWithValue(s.Messagebird.AccessKey.Value) - } - body.SmsMessagebirdOriginator = nullable.NewNullableWithValue(s.Messagebird.Originator) - case s.Textlocal.Enabled: - body.SmsProvider = nullable.NewNullableWithValue(v1API.Textlocal) - if len(s.Textlocal.ApiKey.SHA256) > 0 { - body.SmsTextlocalApiKey = nullable.NewNullableWithValue(s.Textlocal.ApiKey.Value) - } - body.SmsTextlocalSender = nullable.NewNullableWithValue(s.Textlocal.Sender) - case s.Vonage.Enabled: - body.SmsProvider = nullable.NewNullableWithValue(v1API.Vonage) - if len(s.Vonage.ApiSecret.SHA256) > 0 { - body.SmsVonageApiSecret = nullable.NewNullableWithValue(s.Vonage.ApiSecret.Value) - } - body.SmsVonageApiKey = nullable.NewNullableWithValue(s.Vonage.ApiKey) - body.SmsVonageFrom = nullable.NewNullableWithValue(s.Vonage.From) - } -} - -func (s *sms) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - s.EnableSignup = ValOrDefault(remoteConfig.ExternalPhoneEnabled, false) - s.MaxFrequency = time.Duration(ValOrDefault(remoteConfig.SmsMaxFrequency, 0)) * time.Second - s.EnableConfirmations = ValOrDefault(remoteConfig.SmsAutoconfirm, false) - s.Template = ValOrDefault(remoteConfig.SmsTemplate, "") - s.TestOTP = envToMap(ValOrDefault(remoteConfig.SmsTestOtp, "")) - // We are only interested in the provider that's enabled locally - switch { - case s.Twilio.Enabled: - if len(s.Twilio.AuthToken.SHA256) > 0 { - s.Twilio.AuthToken.SHA256 = ValOrDefault(remoteConfig.SmsTwilioAuthToken, "") - } - s.Twilio.AccountSid = ValOrDefault(remoteConfig.SmsTwilioAccountSid, "") - s.Twilio.MessageServiceSid = ValOrDefault(remoteConfig.SmsTwilioMessageServiceSid, "") - case s.TwilioVerify.Enabled: - if len(s.TwilioVerify.AuthToken.SHA256) > 0 { - s.TwilioVerify.AuthToken.SHA256 = ValOrDefault(remoteConfig.SmsTwilioVerifyAuthToken, "") - } - s.TwilioVerify.AccountSid = ValOrDefault(remoteConfig.SmsTwilioVerifyAccountSid, "") - s.TwilioVerify.MessageServiceSid = ValOrDefault(remoteConfig.SmsTwilioVerifyMessageServiceSid, "") - case s.Messagebird.Enabled: - if len(s.Messagebird.AccessKey.SHA256) > 0 { - s.Messagebird.AccessKey.SHA256 = ValOrDefault(remoteConfig.SmsMessagebirdAccessKey, "") - } - s.Messagebird.Originator = ValOrDefault(remoteConfig.SmsMessagebirdOriginator, "") - case s.Textlocal.Enabled: - if len(s.Textlocal.ApiKey.SHA256) > 0 { - s.Textlocal.ApiKey.SHA256 = ValOrDefault(remoteConfig.SmsTextlocalApiKey, "") - } - s.Textlocal.Sender = ValOrDefault(remoteConfig.SmsTextlocalSender, "") - case s.Vonage.Enabled: - if len(s.Vonage.ApiSecret.SHA256) > 0 { - s.Vonage.ApiSecret.SHA256 = ValOrDefault(remoteConfig.SmsVonageApiSecret, "") - } - s.Vonage.ApiKey = ValOrDefault(remoteConfig.SmsVonageApiKey, "") - s.Vonage.From = ValOrDefault(remoteConfig.SmsVonageFrom, "") - case !s.EnableSignup: - // Nothing to do if both local and remote providers are disabled. - return - } - if provider := ValOrDefault(remoteConfig.SmsProvider, ""); len(provider) > 0 { - s.Twilio.Enabled = provider == "twilio" - s.TwilioVerify.Enabled = provider == "twilio_verify" - s.Messagebird.Enabled = provider == "messagebird" - s.Textlocal.Enabled = provider == "textlocal" - s.Vonage.Enabled = provider == "vonage" - } -} - -func (e external) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - if len(e) == 0 { - return - } - // Ignore configs of disabled providers because their envs are not loaded - if p, ok := e["apple"]; ok { - if body.ExternalAppleEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalAppleClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalAppleSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalAppleEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["azure"]; ok { - if body.ExternalAzureEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalAzureClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalAzureSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalAzureUrl = nullable.NewNullableWithValue(p.Url) - body.ExternalAzureEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["bitbucket"]; ok { - if body.ExternalBitbucketEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalBitbucketClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalBitbucketSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalBitbucketEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["discord"]; ok { - if body.ExternalDiscordEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalDiscordClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalDiscordSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalDiscordEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["facebook"]; ok { - if body.ExternalFacebookEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalFacebookClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalFacebookSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalFacebookEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["figma"]; ok { - if body.ExternalFigmaEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalFigmaClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalFigmaSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalFigmaEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["github"]; ok { - if body.ExternalGithubEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalGithubClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalGithubSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalGithubEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["gitlab"]; ok { - if body.ExternalGitlabEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalGitlabClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalGitlabSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalGitlabUrl = nullable.NewNullableWithValue(p.Url) - body.ExternalGitlabEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["google"]; ok { - if body.ExternalGoogleEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalGoogleClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalGoogleSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalGoogleSkipNonceCheck = nullable.NewNullableWithValue(p.SkipNonceCheck) - body.ExternalGoogleEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["kakao"]; ok { - if body.ExternalKakaoEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalKakaoClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalKakaoSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalKakaoEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["keycloak"]; ok { - if body.ExternalKeycloakEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalKeycloakClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalKeycloakSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalKeycloakUrl = nullable.NewNullableWithValue(p.Url) - body.ExternalKeycloakEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["linkedin_oidc"]; ok { - if body.ExternalLinkedinOidcEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalLinkedinOidcClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalLinkedinOidcSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalLinkedinOidcEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["notion"]; ok { - if body.ExternalNotionEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalNotionClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalNotionSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalNotionEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["slack_oidc"]; ok { - if body.ExternalSlackOidcEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalSlackOidcClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalSlackOidcSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalSlackOidcEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["spotify"]; ok { - if body.ExternalSpotifyEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalSpotifyClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalSpotifySecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalSpotifyEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["twitch"]; ok { - if body.ExternalTwitchEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalTwitchClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalTwitchSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalTwitchEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["twitter"]; ok { - if body.ExternalTwitterEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalTwitterClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalTwitterSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalTwitterEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["x"]; ok { - if body.ExternalXEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalXClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalXSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalXEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } - if p, ok := e["workos"]; ok { - if body.ExternalWorkosEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalWorkosClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalWorkosSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalWorkosUrl = nullable.NewNullableWithValue(p.Url) - } - } - if p, ok := e["zoom"]; ok { - if body.ExternalZoomEnabled = nullable.NewNullableWithValue(p.Enabled); p.Enabled { - body.ExternalZoomClientId = nullable.NewNullableWithValue(p.ClientId) - if len(p.Secret.SHA256) > 0 { - body.ExternalZoomSecret = nullable.NewNullableWithValue(p.Secret.Value) - } - body.ExternalZoomEmailOptional = nullable.NewNullableWithValue(p.EmailOptional) - } - } -} - -func (e external) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - if len(e) == 0 { - return - } - // Ignore configs of disabled providers because their envs are not loaded - if p, ok := e["apple"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalAppleClientId, "") - if ids := ValOrDefault(remoteConfig.ExternalAppleAdditionalClientIds, ""); len(ids) > 0 { - p.ClientId += "," + ids - } - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalAppleSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalAppleEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalAppleEnabled, false) - e["apple"] = p - } - - if p, ok := e["azure"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalAzureClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalAzureSecret, "") - } - p.Url = ValOrDefault(remoteConfig.ExternalAzureUrl, "") - p.EmailOptional = ValOrDefault(remoteConfig.ExternalAzureEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalAzureEnabled, false) - e["azure"] = p - } - - if p, ok := e["bitbucket"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalBitbucketClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalBitbucketSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalBitbucketEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalBitbucketEnabled, false) - e["bitbucket"] = p - } - - if p, ok := e["discord"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalDiscordClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalDiscordSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalDiscordEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalDiscordEnabled, false) - e["discord"] = p - } - - if p, ok := e["facebook"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalFacebookClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalFacebookSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalFacebookEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalFacebookEnabled, false) - e["facebook"] = p - } - - if p, ok := e["figma"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalFigmaClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalFigmaSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalFigmaEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalFigmaEnabled, false) - e["figma"] = p - } - - if p, ok := e["github"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalGithubClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalGithubSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalGithubEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalGithubEnabled, false) - e["github"] = p - } - - if p, ok := e["gitlab"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalGitlabClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalGitlabSecret, "") - } - p.Url = ValOrDefault(remoteConfig.ExternalGitlabUrl, "") - p.EmailOptional = ValOrDefault(remoteConfig.ExternalGitlabEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalGitlabEnabled, false) - e["gitlab"] = p - } - - if p, ok := e["google"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalGoogleClientId, "") - if ids := ValOrDefault(remoteConfig.ExternalGoogleAdditionalClientIds, ""); len(ids) > 0 { - p.ClientId += "," + ids - } - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalGoogleSecret, "") - } - p.SkipNonceCheck = ValOrDefault(remoteConfig.ExternalGoogleSkipNonceCheck, false) - p.EmailOptional = ValOrDefault(remoteConfig.ExternalGoogleEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalGoogleEnabled, false) - e["google"] = p - } - - if p, ok := e["kakao"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalKakaoClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalKakaoSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalKakaoEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalKakaoEnabled, false) - e["kakao"] = p - } - - if p, ok := e["keycloak"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalKeycloakClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalKeycloakSecret, "") - } - p.Url = ValOrDefault(remoteConfig.ExternalKeycloakUrl, "") - p.EmailOptional = ValOrDefault(remoteConfig.ExternalKeycloakEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalKeycloakEnabled, false) - e["keycloak"] = p - } - - if p, ok := e["linkedin_oidc"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalLinkedinOidcClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalLinkedinOidcSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalLinkedinOidcEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalLinkedinOidcEnabled, false) - e["linkedin_oidc"] = p - } - - if p, ok := e["notion"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalNotionClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalNotionSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalNotionEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalNotionEnabled, false) - e["notion"] = p - } - - if p, ok := e["slack_oidc"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalSlackOidcClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalSlackOidcSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalSlackOidcEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalSlackOidcEnabled, false) - e["slack_oidc"] = p - } - - if p, ok := e["spotify"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalSpotifyClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalSpotifySecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalSpotifyEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalSpotifyEnabled, false) - e["spotify"] = p - } - - if p, ok := e["twitch"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalTwitchClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalTwitchSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalTwitchEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalTwitchEnabled, false) - e["twitch"] = p - } - - if p, ok := e["twitter"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalTwitterClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalTwitterSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalTwitterEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalTwitterEnabled, false) - e["twitter"] = p - } - - if p, ok := e["x"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalXClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalXSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalXEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalXEnabled, false) - e["x"] = p - } - - if p, ok := e["workos"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalWorkosClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalWorkosSecret, "") - } - p.Url = ValOrDefault(remoteConfig.ExternalWorkosUrl, "") - } - p.Enabled = ValOrDefault(remoteConfig.ExternalWorkosEnabled, false) - e["workos"] = p - } - - if p, ok := e["zoom"]; ok { - if p.Enabled { - p.ClientId = ValOrDefault(remoteConfig.ExternalZoomClientId, "") - if len(p.Secret.SHA256) > 0 { - p.Secret.SHA256 = ValOrDefault(remoteConfig.ExternalZoomSecret, "") - } - p.EmailOptional = ValOrDefault(remoteConfig.ExternalZoomEmailOptional, false) - } - p.Enabled = ValOrDefault(remoteConfig.ExternalZoomEnabled, false) - e["zoom"] = p - } -} - -func (w web3) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.ExternalWeb3SolanaEnabled = nullable.NewNullableWithValue(w.Solana.Enabled) - body.ExternalWeb3EthereumEnabled = nullable.NewNullableWithValue(w.Ethereum.Enabled) -} - -func (w *web3) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - if value, err := remoteConfig.ExternalWeb3SolanaEnabled.Get(); err == nil { - w.Solana.Enabled = value - } - - if value, err := remoteConfig.ExternalWeb3EthereumEnabled.Get(); err == nil { - w.Ethereum.Enabled = value - } -} - -func (o OAuthServer) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - // TODO(cemal) :: implement me - // OAuth server configuration is behind a feature flag in the remote API - // Will be implemented when the feature reaches GA -} - -func (o *OAuthServer) fromAuthConfig(remoteConfig v1API.AuthConfigResponseOutput) { - // TODO(cemal) :: implement me - // OAuth server configuration is behind a feature flag in the remote API - // Will be implemented when the feature reaches GA -} - -func (a *auth) DiffWithRemote(remoteConfig v1API.AuthConfigResponseOutput, filter ...func(string) bool) ([]byte, error) { - copy := a.Clone() - copy.FromRemoteAuthConfig(remoteConfig) - // Confirm cost before enabling addons - for _, keep := range filter { - if a.MFA.Phone.VerifyEnabled && !copy.MFA.Phone.VerifyEnabled { - if !keep(string(v1API.ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaPhone)) { - a.MFA.Phone.VerifyEnabled = false - // Enroll cannot be enabled on its own - a.MFA.Phone.EnrollEnabled = false - } - } - if a.MFA.WebAuthn.VerifyEnabled && !copy.MFA.WebAuthn.VerifyEnabled { - if !keep(string(v1API.ListProjectAddonsResponseOutputAvailableAddonsTypeAuthMfaWebAuthn)) { - a.MFA.WebAuthn.VerifyEnabled = false - // Enroll cannot be enabled on its own - a.MFA.WebAuthn.EnrollEnabled = false - } - } - } - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(a) - if err != nil { - return nil, err - } - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[auth]", remoteCompare, "local[auth]", currentValue), nil -} - -func ValOrDefault[T any](v nullable.Nullable[T], def T) T { - if value, err := v.Get(); err == nil { - return value - } - return def -} diff --git a/apps/cli-go/pkg/config/auth_test.go b/apps/cli-go/pkg/config/auth_test.go deleted file mode 100644 index 50772a5571..0000000000 --- a/apps/cli-go/pkg/config/auth_test.go +++ /dev/null @@ -1,1497 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/go-errors/errors" - "github.com/oapi-codegen/nullable" - openapi_types "github.com/oapi-codegen/runtime/types" - "github.com/stretchr/testify/assert" - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" -) - -func newWithDefaults() auth { - return auth{ - EnableSignup: true, - AdditionalRedirectUrls: []string{}, - Email: email{ - EnableConfirmations: true, - }, - Sms: sms{ - TestOTP: map[string]string{}, - }, - } -} - -func assertSnapshotEqual(t *testing.T, actual []byte) { - snapshot := filepath.Join("testdata", filepath.FromSlash(t.Name())) + ".diff" - expected, err := os.ReadFile(snapshot) - if errors.Is(err, os.ErrNotExist) { - assert.NoError(t, os.MkdirAll(filepath.Dir(snapshot), 0755)) - assert.NoError(t, os.WriteFile(snapshot, actual, 0600)) - } - assert.Equal(t, string(expected), string(actual)) -} - -func TestAuthDiff(t *testing.T) { - t.Run("local and remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.SiteUrl = "http://127.0.0.1:3000" - c.AdditionalRedirectUrls = []string{"https://127.0.0.1:3000"} - c.JwtExpiry = 3600 - c.EnableRefreshTokenRotation = true - c.RefreshTokenReuseInterval = 10 - c.EnableManualLinking = true - c.EnableSignup = true - c.EnableAnonymousSignIns = true - c.MinimumPasswordLength = 6 - c.PasswordRequirements = LettersDigits - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SiteUrl: nullable.NewNullableWithValue("http://127.0.0.1:3000"), - UriAllowList: nullable.NewNullableWithValue("https://127.0.0.1:3000"), - JwtExp: nullable.NewNullableWithValue(3600), - RefreshTokenRotationEnabled: nullable.NewNullableWithValue(true), - SecurityRefreshTokenReuseInterval: nullable.NewNullableWithValue(10), - SecurityManualLinkingEnabled: nullable.NewNullableWithValue(true), - DisableSignup: nullable.NewNullableWithValue(false), - ExternalAnonymousUsersEnabled: nullable.NewNullableWithValue(true), - PasswordMinLength: nullable.NewNullableWithValue(6), - PasswordRequiredCharacters: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local enabled and disabled", func(t *testing.T) { - c := newWithDefaults() - c.SiteUrl = "http://127.0.0.1:3000" - c.AdditionalRedirectUrls = []string{"https://127.0.0.1:3000"} - c.JwtExpiry = 3600 - c.EnableRefreshTokenRotation = false - c.RefreshTokenReuseInterval = 10 - c.EnableManualLinking = false - c.EnableSignup = false - c.EnableAnonymousSignIns = false - c.MinimumPasswordLength = 6 - c.PasswordRequirements = LowerUpperLettersDigitsSymbols - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SiteUrl: nullable.NewNullableWithValue(""), - UriAllowList: nullable.NewNullableWithValue("https://127.0.0.1:3000,https://ref.supabase.co"), - JwtExp: nullable.NewNullableWithValue(0), - RefreshTokenRotationEnabled: nullable.NewNullableWithValue(true), - SecurityRefreshTokenReuseInterval: nullable.NewNullableWithValue(0), - SecurityManualLinkingEnabled: nullable.NewNullableWithValue(true), - DisableSignup: nullable.NewNullableWithValue(false), - ExternalAnonymousUsersEnabled: nullable.NewNullableWithValue(true), - PasswordMinLength: nullable.NewNullableWithValue(8), - PasswordRequiredCharacters: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputPasswordRequiredCharactersAbcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local and remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.EnableSignup = false - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SiteUrl: nullable.NewNullableWithValue(""), - UriAllowList: nullable.NewNullableWithValue(""), - JwtExp: nullable.NewNullableWithValue(0), - RefreshTokenRotationEnabled: nullable.NewNullableWithValue(false), - SecurityRefreshTokenReuseInterval: nullable.NewNullableWithValue(0), - SecurityManualLinkingEnabled: nullable.NewNullableWithValue(false), - DisableSignup: nullable.NewNullableWithValue(true), - ExternalAnonymousUsersEnabled: nullable.NewNullableWithValue(false), - PasswordMinLength: nullable.NewNullableWithValue(0), - PasswordRequiredCharacters: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputPasswordRequiredCharactersEmpty), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestCaptchaDiff(t *testing.T) { - t.Run("local and remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Captcha = &captcha{ - Enabled: true, - Provider: HCaptchaProvider, - Secret: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SecurityCaptchaEnabled: nullable.NewNullableWithValue(true), - SecurityCaptchaProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha), - SecurityCaptchaSecret: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local disabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Captcha = &captcha{ - Enabled: false, - Provider: TurnstileProvider, - Secret: Secret{ - Value: "test-key", - SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e", - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SecurityCaptchaEnabled: nullable.NewNullableWithValue(true), - SecurityCaptchaProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha), - SecurityCaptchaSecret: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local enabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Captcha = &captcha{ - Enabled: true, - Provider: TurnstileProvider, - Secret: Secret{ - Value: "test-key", - SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e", - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SecurityCaptchaEnabled: nullable.NewNullableWithValue(false), - SecurityCaptchaProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha), - SecurityCaptchaSecret: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local and remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Captcha = &captcha{ - Enabled: false, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SecurityCaptchaEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("ignores undefined config", func(t *testing.T) { - c := newWithDefaults() - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - SecurityCaptchaEnabled: nullable.NewNullableWithValue(true), - SecurityCaptchaProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSecurityCaptchaProviderHcaptcha), - SecurityCaptchaSecret: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestPasskeyConfigMapping(t *testing.T) { - t.Run("serializes passkey config to update body", func(t *testing.T) { - c := newWithDefaults() - c.Passkey = &Passkey{Enabled: true} - c.Webauthn = &Webauthn{ - RpDisplayName: "Supabase CLI", - RpId: "localhost", - RpOrigins: []string{ - "http://127.0.0.1:3000", - "https://localhost:3000", - }, - } - // Run test - body := c.ToUpdateAuthConfigBody() - // Check result - if assert.NotNil(t, body.PasskeyEnabled) { - assert.True(t, *body.PasskeyEnabled) - } - assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, "")) - assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, "")) - assert.Equal(t, "http://127.0.0.1:3000,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, "")) - }) - - t.Run("does not serialize rp fields when webauthn is undefined", func(t *testing.T) { - c := newWithDefaults() - c.Passkey = &Passkey{Enabled: false} - // Run test - body := c.ToUpdateAuthConfigBody() - // Check result - if assert.NotNil(t, body.PasskeyEnabled) { - assert.False(t, *body.PasskeyEnabled) - } - _, err := body.WebauthnRpDisplayName.Get() - assert.Error(t, err) - _, err = body.WebauthnRpId.Get() - assert.Error(t, err) - _, err = body.WebauthnRpOrigins.Get() - assert.Error(t, err) - }) - - t.Run("serializes webauthn fields independently of passkey", func(t *testing.T) { - c := newWithDefaults() - c.Webauthn = &Webauthn{ - RpDisplayName: "Supabase CLI", - RpId: "localhost", - RpOrigins: []string{"http://127.0.0.1:3000"}, - } - // Run test - body := c.ToUpdateAuthConfigBody() - // Check result - assert.Nil(t, body.PasskeyEnabled) - assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, "")) - assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, "")) - assert.Equal(t, "http://127.0.0.1:3000", ValOrDefault(body.WebauthnRpOrigins, "")) - }) - - t.Run("hydrates passkey and webauthn config from remote", func(t *testing.T) { - c := newWithDefaults() - c.Passkey = &Passkey{Enabled: true} - c.Webauthn = &Webauthn{} - // Run test - c.FromRemoteAuthConfig(v1API.AuthConfigResponseOutput{ - PasskeyEnabled: true, - WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), - WebauthnRpId: nullable.NewNullableWithValue("localhost"), - WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000,https://localhost:3000"), - }) - // Check result - if assert.NotNil(t, c.Passkey) { - assert.True(t, c.Passkey.Enabled) - } - if assert.NotNil(t, c.Webauthn) { - assert.Equal(t, "Supabase CLI", c.Webauthn.RpDisplayName) - assert.Equal(t, "localhost", c.Webauthn.RpId) - assert.Equal(t, []string{ - "http://127.0.0.1:3000", - "https://localhost:3000", - }, c.Webauthn.RpOrigins) - } - }) - - t.Run("ignores remote settings when local passkey config is undefined", func(t *testing.T) { - c := newWithDefaults() - // Run test - c.FromRemoteAuthConfig(v1API.AuthConfigResponseOutput{ - PasskeyEnabled: true, - WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), - WebauthnRpId: nullable.NewNullableWithValue("localhost"), - WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"), - }) - // Check result - assert.Nil(t, c.Passkey) - assert.Nil(t, c.Webauthn) - }) -} - -func TestPasskeyDiff(t *testing.T) { - t.Run("ignores undefined config", func(t *testing.T) { - c := newWithDefaults() - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - PasskeyEnabled: true, - WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), - WebauthnRpId: nullable.NewNullableWithValue("localhost"), - WebauthnRpOrigins: nullable.NewNullableWithValue("http://127.0.0.1:3000"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestHookDiff(t *testing.T) { - t.Run("local and remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Hook = hook{ - BeforeUserCreated: &hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - CustomAccessToken: &hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - SendSMS: &hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - SendEmail: &hookConfig{ - Enabled: true, - URI: "https://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - MFAVerificationAttempt: &hookConfig{ - Enabled: true, - URI: "https://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - PasswordVerificationAttempt: &hookConfig{ - Enabled: true, - URI: "pg-functions://verifyPassword", - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - HookBeforeUserCreatedEnabled: nullable.NewNullableWithValue(true), - HookBeforeUserCreatedUri: nullable.NewNullableWithValue("http://example.com"), - HookBeforeUserCreatedSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookCustomAccessTokenEnabled: nullable.NewNullableWithValue(true), - HookCustomAccessTokenUri: nullable.NewNullableWithValue("http://example.com"), - HookCustomAccessTokenSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookSendSmsEnabled: nullable.NewNullableWithValue(true), - HookSendSmsUri: nullable.NewNullableWithValue("http://example.com"), - HookSendSmsSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookSendEmailEnabled: nullable.NewNullableWithValue(true), - HookSendEmailUri: nullable.NewNullableWithValue("https://example.com"), - HookSendEmailSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookMfaVerificationAttemptEnabled: nullable.NewNullableWithValue(true), - HookMfaVerificationAttemptUri: nullable.NewNullableWithValue("https://example.com"), - HookMfaVerificationAttemptSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookPasswordVerificationAttemptEnabled: nullable.NewNullableWithValue(true), - HookPasswordVerificationAttemptUri: nullable.NewNullableWithValue("pg-functions://verifyPassword"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local disabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Hook = hook{ - BeforeUserCreated: &hookConfig{ - Enabled: false, - }, - CustomAccessToken: &hookConfig{ - Enabled: false, - }, - SendSMS: &hookConfig{ - Enabled: false, - URI: "https://example.com", - Secrets: Secret{Value: "test-secret"}, - }, - SendEmail: &hookConfig{ - Enabled: false, - }, - MFAVerificationAttempt: &hookConfig{ - Enabled: false, - URI: "pg-functions://postgres/public/verifyMFA", - }, - PasswordVerificationAttempt: nil, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - HookBeforeUserCreatedEnabled: nullable.NewNullableWithValue(true), - HookBeforeUserCreatedUri: nullable.NewNullableWithValue("http://example.com"), - HookBeforeUserCreatedSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookCustomAccessTokenEnabled: nullable.NewNullableWithValue(true), - HookCustomAccessTokenUri: nullable.NewNullableWithValue("http://example.com"), - HookCustomAccessTokenSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookSendSmsEnabled: nullable.NewNullableWithValue(true), - HookSendSmsUri: nullable.NewNullableWithValue("https://example.com"), - HookSendSmsSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookSendEmailEnabled: nullable.NewNullableWithValue(true), - HookSendEmailUri: nullable.NewNullableWithValue("pg-functions://postgres/public/sendEmail"), - HookMfaVerificationAttemptEnabled: nullable.NewNullableWithValue(true), - HookMfaVerificationAttemptUri: nullable.NewNullableWithValue("pg-functions://postgres/public/verifyMFA"), - HookPasswordVerificationAttemptEnabled: nullable.NewNullableWithValue(true), - HookPasswordVerificationAttemptUri: nullable.NewNullableWithValue("https://example.com"), - HookPasswordVerificationAttemptSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local enabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Hook = hook{ - BeforeUserCreated: &hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - CustomAccessToken: &hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - SendSMS: &hookConfig{ - Enabled: true, - URI: "https://example.com", - Secrets: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - SendEmail: &hookConfig{ - Enabled: true, - URI: "pg-functions://postgres/public/sendEmail", - }, - MFAVerificationAttempt: &hookConfig{ - Enabled: true, - URI: "pg-functions://postgres/public/verifyMFA", - }, - PasswordVerificationAttempt: nil, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - HookBeforeUserCreatedEnabled: nullable.NewNullableWithValue(false), - HookBeforeUserCreatedUri: nullable.NewNullableWithValue("pg-functions://postgres/public/beforeUserCreated"), - HookCustomAccessTokenEnabled: nullable.NewNullableWithValue(false), - HookCustomAccessTokenUri: nullable.NewNullableWithValue("pg-functions://postgres/public/customToken"), - HookSendSmsEnabled: nullable.NewNullableWithValue(false), - HookSendSmsUri: nullable.NewNullableWithValue("https://example.com"), - HookSendSmsSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookSendEmailEnabled: nullable.NewNullableWithValue(false), - HookSendEmailUri: nullable.NewNullableWithValue("https://example.com"), - HookSendEmailSecrets: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - HookMfaVerificationAttemptEnabled: nullable.NewNullableWithValue(false), - HookMfaVerificationAttemptUri: nullable.NewNullableWithValue("pg-functions://postgres/public/verifyMFA"), - HookPasswordVerificationAttemptEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local and remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Hook = hook{ - BeforeUserCreated: &hookConfig{Enabled: false}, - CustomAccessToken: &hookConfig{Enabled: false}, - SendSMS: &hookConfig{Enabled: false}, - SendEmail: &hookConfig{Enabled: false}, - MFAVerificationAttempt: &hookConfig{Enabled: false}, - PasswordVerificationAttempt: &hookConfig{Enabled: false}, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - HookBeforeUserCreatedEnabled: nullable.NewNullableWithValue(false), - HookCustomAccessTokenEnabled: nullable.NewNullableWithValue(false), - HookSendSmsEnabled: nullable.NewNullableWithValue(false), - HookSendEmailEnabled: nullable.NewNullableWithValue(false), - HookMfaVerificationAttemptEnabled: nullable.NewNullableWithValue(false), - HookPasswordVerificationAttemptEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestMfaDiff(t *testing.T) { - t.Run("local and remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.MFA = mfa{ - TOTP: factorTypeConfiguration{ - EnrollEnabled: true, - VerifyEnabled: true, - }, - Phone: phoneFactorTypeConfiguration{ - factorTypeConfiguration: factorTypeConfiguration{ - EnrollEnabled: true, - VerifyEnabled: true, - }, - OtpLength: 6, - Template: "Your code is {{ .Code }}", - MaxFrequency: 5 * time.Second, - }, - WebAuthn: factorTypeConfiguration{ - EnrollEnabled: true, - VerifyEnabled: true, - }, - MaxEnrolledFactors: 10, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - MfaMaxEnrolledFactors: nullable.NewNullableWithValue(10), - MfaTotpEnrollEnabled: nullable.NewNullableWithValue(true), - MfaTotpVerifyEnabled: nullable.NewNullableWithValue(true), - MfaPhoneEnrollEnabled: nullable.NewNullableWithValue(true), - MfaPhoneVerifyEnabled: nullable.NewNullableWithValue(true), - MfaPhoneOtpLength: 6, - MfaPhoneTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - MfaPhoneMaxFrequency: nullable.NewNullableWithValue(5), - MfaWebAuthnEnrollEnabled: nullable.NewNullableWithValue(true), - MfaWebAuthnVerifyEnabled: nullable.NewNullableWithValue(true), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local enabled and disabled", func(t *testing.T) { - c := newWithDefaults() - c.MFA = mfa{ - TOTP: factorTypeConfiguration{ - EnrollEnabled: false, - VerifyEnabled: false, - }, - Phone: phoneFactorTypeConfiguration{ - factorTypeConfiguration: factorTypeConfiguration{ - EnrollEnabled: true, - VerifyEnabled: true, - }, - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - MfaMaxEnrolledFactors: nullable.NewNullableWithValue(10), - MfaTotpEnrollEnabled: nullable.NewNullableWithValue(false), - MfaTotpVerifyEnabled: nullable.NewNullableWithValue(false), - MfaPhoneEnrollEnabled: nullable.NewNullableWithValue(false), - MfaPhoneVerifyEnabled: nullable.NewNullableWithValue(false), - MfaPhoneOtpLength: 6, - MfaPhoneTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - MfaPhoneMaxFrequency: nullable.NewNullableWithValue(5), - MfaWebAuthnEnrollEnabled: nullable.NewNullableWithValue(false), - MfaWebAuthnVerifyEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local and remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.MFA = mfa{ - MaxEnrolledFactors: 10, - Phone: phoneFactorTypeConfiguration{ - OtpLength: 6, - Template: "Your code is {{ .Code }}", - MaxFrequency: 5 * time.Second, - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - MfaMaxEnrolledFactors: nullable.NewNullableWithValue(10), - MfaTotpEnrollEnabled: nullable.NewNullableWithValue(false), - MfaTotpVerifyEnabled: nullable.NewNullableWithValue(false), - MfaPhoneEnrollEnabled: nullable.NewNullableWithValue(false), - MfaPhoneVerifyEnabled: nullable.NewNullableWithValue(false), - MfaPhoneOtpLength: 6, - MfaPhoneTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - MfaPhoneMaxFrequency: nullable.NewNullableWithValue(5), - MfaWebAuthnEnrollEnabled: nullable.NewNullableWithValue(false), - MfaWebAuthnVerifyEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestEmailDiff(t *testing.T) { - t.Run("local enabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Email = email{ - EnableSignup: true, - DoubleConfirmChanges: true, - EnableConfirmations: true, - SecurePasswordChange: true, - Template: map[string]emailTemplate{ - "invite": { - Subject: cast.Ptr("invite-subject"), - Content: cast.Ptr("invite-content"), - }, - "confirmation": { - Subject: cast.Ptr("confirmation-subject"), - Content: cast.Ptr("confirmation-content"), - }, - "recovery": { - Subject: cast.Ptr("recovery-subject"), - Content: cast.Ptr("recovery-content"), - }, - "magic_link": { - Subject: cast.Ptr("magic-link-subject"), - Content: cast.Ptr("magic-link-content"), - }, - "email_change": { - Subject: cast.Ptr("email-change-subject"), - Content: cast.Ptr("email-change-content"), - }, - "reauthentication": { - Subject: cast.Ptr("reauthentication-subject"), - Content: cast.Ptr("reauthentication-content"), - }, - }, - Notification: map[string]notification{ - "password_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("password-changed-subject"), - Content: cast.Ptr("password-changed-content"), - }, - }, - "email_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("email-changed-subject"), - Content: cast.Ptr("email-changed-content"), - }, - }, - "phone_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("phone-changed-subject"), - Content: cast.Ptr("phone-changed-content"), - }, - }, - "identity_linked": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("identity-linked-subject"), - Content: cast.Ptr("identity-linked-content"), - }, - }, - "identity_unlinked": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("identity-unlinked-subject"), - Content: cast.Ptr("identity-unlinked-content"), - }, - }, - "mfa_factor_enrolled": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("mfa-enrolled-subject"), - Content: cast.Ptr("mfa-enrolled-content"), - }, - }, - "mfa_factor_unenrolled": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("mfa-unenrolled-subject"), - Content: cast.Ptr("mfa-unenrolled-content"), - }, - }, - }, - Smtp: &smtp{ - Enabled: true, - Host: "smtp.sendgrid.net", - Port: 587, - User: "apikey", - Pass: Secret{ - Value: "test-key", - SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e", - }, - AdminEmail: openapi_types.Email("admin@email.com"), - SenderName: "Admin", - }, - MaxFrequency: time.Second, - OtpLength: 6, - OtpExpiry: 3600, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalEmailEnabled: nullable.NewNullableWithValue(true), - MailerSecureEmailChangeEnabled: nullable.NewNullableWithValue(true), - MailerAutoconfirm: nullable.NewNullableWithValue(false), - MailerOtpLength: nullable.NewNullableWithValue(6), - MailerOtpExp: 3600, - SecurityUpdatePasswordRequireReauthentication: nullable.NewNullableWithValue(true), - SmtpHost: nullable.NewNullableWithValue("smtp.sendgrid.net"), - SmtpPort: nullable.NewNullableWithValue("587"), - SmtpUser: nullable.NewNullableWithValue("apikey"), - SmtpPass: nullable.NewNullableWithValue("ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"), - SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("admin@email.com")), - SmtpSenderName: nullable.NewNullableWithValue("Admin"), - SmtpMaxFrequency: nullable.NewNullableWithValue(1), - // Custom templates - MailerSubjectsInvite: nullable.NewNullableWithValue("invite-subject"), - MailerTemplatesInviteContent: nullable.NewNullableWithValue("invite-content"), - MailerSubjectsConfirmation: nullable.NewNullableWithValue("confirmation-subject"), - MailerTemplatesConfirmationContent: nullable.NewNullableWithValue("confirmation-content"), - MailerSubjectsRecovery: nullable.NewNullableWithValue("recovery-subject"), - MailerTemplatesRecoveryContent: nullable.NewNullableWithValue("recovery-content"), - MailerSubjectsMagicLink: nullable.NewNullableWithValue("magic-link-subject"), - MailerTemplatesMagicLinkContent: nullable.NewNullableWithValue("magic-link-content"), - MailerSubjectsEmailChange: nullable.NewNullableWithValue("email-change-subject"), - MailerTemplatesEmailChangeContent: nullable.NewNullableWithValue("email-change-content"), - MailerSubjectsReauthentication: nullable.NewNullableWithValue("reauthentication-subject"), - MailerTemplatesReauthenticationContent: nullable.NewNullableWithValue("reauthentication-content"), - // Notifications - MailerNotificationsPasswordChangedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsPasswordChangedNotification: nullable.NewNullableWithValue("password-changed-subject"), - MailerTemplatesPasswordChangedNotificationContent: nullable.NewNullableWithValue("password-changed-content"), - MailerNotificationsEmailChangedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsEmailChangedNotification: nullable.NewNullableWithValue("email-changed-subject"), - MailerTemplatesEmailChangedNotificationContent: nullable.NewNullableWithValue("email-changed-content"), - MailerNotificationsPhoneChangedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsPhoneChangedNotification: nullable.NewNullableWithValue("phone-changed-subject"), - MailerTemplatesPhoneChangedNotificationContent: nullable.NewNullableWithValue("phone-changed-content"), - MailerNotificationsIdentityLinkedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsIdentityLinkedNotification: nullable.NewNullableWithValue("identity-linked-subject"), - MailerTemplatesIdentityLinkedNotificationContent: nullable.NewNullableWithValue("identity-linked-content"), - MailerNotificationsIdentityUnlinkedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsIdentityUnlinkedNotification: nullable.NewNullableWithValue("identity-unlinked-subject"), - MailerTemplatesIdentityUnlinkedNotificationContent: nullable.NewNullableWithValue("identity-unlinked-content"), - MailerNotificationsMfaFactorEnrolledEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsMfaFactorEnrolledNotification: nullable.NewNullableWithValue("mfa-enrolled-subject"), - MailerTemplatesMfaFactorEnrolledNotificationContent: nullable.NewNullableWithValue("mfa-enrolled-content"), - MailerNotificationsMfaFactorUnenrolledEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsMfaFactorUnenrolledNotification: nullable.NewNullableWithValue("mfa-unenrolled-subject"), - MailerTemplatesMfaFactorUnenrolledNotificationContent: nullable.NewNullableWithValue("mfa-unenrolled-content"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local enabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Email = email{ - EnableSignup: true, - DoubleConfirmChanges: true, - EnableConfirmations: true, - SecurePasswordChange: true, - Template: map[string]emailTemplate{ - "invite": { - Subject: cast.Ptr("invite-subject"), - Content: cast.Ptr("invite-content"), - }, - "confirmation": { - Subject: cast.Ptr("confirmation-subject"), - }, - "recovery": { - Content: cast.Ptr("recovery-content"), - }, - "magic_link": { - Subject: cast.Ptr("magic-link-subject"), - Content: cast.Ptr("magic-link-content"), - }, - "email_change": { - Subject: cast.Ptr("email-change-subject"), - Content: cast.Ptr("email-change-content"), - }, - "reauthentication": { - Subject: cast.Ptr(""), - Content: cast.Ptr(""), - }, - }, - Notification: map[string]notification{ - "password_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("password-changed-subject"), - Content: cast.Ptr("password-changed-content"), - }, - }, - "email_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("email-changed-subject"), - Content: cast.Ptr("email-changed-content"), - }, - }, - "phone_changed": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("phone-changed-subject"), - Content: cast.Ptr("phone-changed-content"), - }, - }, - "identity_linked": { - Enabled: true, - emailTemplate: emailTemplate{ - Subject: cast.Ptr("identity-linked-subject"), - Content: cast.Ptr("identity-linked-content"), - }, - }, - "identity_unlinked": { - Enabled: true, - }, - "mfa_factor_enrolled": { - Enabled: true, - }, - "mfa_factor_unenrolled": { - Enabled: true, - }, - }, - Smtp: &smtp{ - Enabled: true, - Host: "smtp.sendgrid.net", - Port: 587, - User: "apikey", - Pass: Secret{ - Value: "test-key", - SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e", - }, - AdminEmail: openapi_types.Email("admin@email.com"), - SenderName: "Admin", - }, - MaxFrequency: time.Second, - OtpLength: 8, - OtpExpiry: 86400, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalEmailEnabled: nullable.NewNullableWithValue(false), - MailerSecureEmailChangeEnabled: nullable.NewNullableWithValue(false), - MailerAutoconfirm: nullable.NewNullableWithValue(true), - MailerOtpLength: nullable.NewNullableWithValue(6), - MailerOtpExp: 3600, - SecurityUpdatePasswordRequireReauthentication: nullable.NewNullableWithValue(false), - SmtpMaxFrequency: nullable.NewNullableWithValue(60), - // Custom templates - MailerTemplatesConfirmationContent: nullable.NewNullableWithValue("confirmation-content"), - MailerSubjectsRecovery: nullable.NewNullableWithValue("recovery-subject"), - MailerSubjectsMagicLink: nullable.NewNullableWithValue("magic-link-subject"), - MailerTemplatesEmailChangeContent: nullable.NewNullableWithValue("email-change-content"), - // Notifications - MailerNotificationsPasswordChangedEnabled: nullable.NewNullableWithValue(false), - MailerSubjectsPasswordChangedNotification: nullable.NewNullableWithValue("password-changed-subject"), - MailerTemplatesPasswordChangedNotificationContent: nullable.NewNullableWithValue("password-changed-content"), - MailerNotificationsEmailChangedEnabled: nullable.NewNullableWithValue(false), - MailerSubjectsEmailChangedNotification: nullable.NewNullableWithValue("email-changed-subject"), - MailerNotificationsPhoneChangedEnabled: nullable.NewNullableWithValue(false), - MailerTemplatesPhoneChangedNotificationContent: nullable.NewNullableWithValue("phone-changed-content"), - MailerNotificationsIdentityLinkedEnabled: nullable.NewNullableWithValue(false), - MailerNotificationsIdentityUnlinkedEnabled: nullable.NewNullableWithValue(false), - MailerSubjectsIdentityUnlinkedNotification: nullable.NewNullableWithValue("identity-unlinked-subject"), - MailerTemplatesIdentityUnlinkedNotificationContent: nullable.NewNullableWithValue("identity-unlinked-content"), - MailerNotificationsMfaFactorEnrolledEnabled: nullable.NewNullableWithValue(false), - MailerSubjectsMfaFactorEnrolledNotification: nullable.NewNullableWithValue("mfa-enrolled-subject"), - MailerNotificationsMfaFactorUnenrolledEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local disabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Email = email{ - EnableConfirmations: false, - Template: map[string]emailTemplate{ - "invite": {}, - "confirmation": {}, - "recovery": {}, - "magic_link": {}, - "email_change": {}, - "reauthentication": {}, - }, - Notification: map[string]notification{ - "password_changed": {}, - "email_changed": {}, - "phone_changed": {}, - "identity_linked": {}, - "identity_unlinked": {}, - "mfa_factor_enrolled": {}, - "mfa_factor_unenrolled": {}, - }, - MaxFrequency: time.Minute, - OtpLength: 8, - OtpExpiry: 86400, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalEmailEnabled: nullable.NewNullableWithValue(true), - MailerSecureEmailChangeEnabled: nullable.NewNullableWithValue(true), - MailerAutoconfirm: nullable.NewNullableWithValue(false), - MailerOtpLength: nullable.NewNullableWithValue(6), - MailerOtpExp: 3600, - SecurityUpdatePasswordRequireReauthentication: nullable.NewNullableWithValue(true), - SmtpHost: nullable.NewNullableWithValue("smtp.sendgrid.net"), - SmtpPort: nullable.NewNullableWithValue("587"), - SmtpUser: nullable.NewNullableWithValue("apikey"), - SmtpPass: nullable.NewNullableWithValue("ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"), - SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("admin@email.com")), - SmtpSenderName: nullable.NewNullableWithValue("Admin"), - SmtpMaxFrequency: nullable.NewNullableWithValue(1), - // Custom templates - MailerSubjectsInvite: nullable.NewNullableWithValue("invite-subject"), - MailerTemplatesInviteContent: nullable.NewNullableWithValue("invite-content"), - MailerSubjectsConfirmation: nullable.NewNullableWithValue("confirmation-subject"), - MailerTemplatesConfirmationContent: nullable.NewNullableWithValue("confirmation-content"), - MailerSubjectsRecovery: nullable.NewNullableWithValue("recovery-subject"), - MailerTemplatesRecoveryContent: nullable.NewNullableWithValue("recovery-content"), - MailerSubjectsMagicLink: nullable.NewNullableWithValue("magic-link-subject"), - MailerTemplatesMagicLinkContent: nullable.NewNullableWithValue("magic-link-content"), - MailerSubjectsEmailChange: nullable.NewNullableWithValue("email-change-subject"), - MailerTemplatesEmailChangeContent: nullable.NewNullableWithValue("email-change-content"), - MailerSubjectsReauthentication: nullable.NewNullableWithValue("reauthentication-subject"), - MailerTemplatesReauthenticationContent: nullable.NewNullableWithValue("reauthentication-content"), - // Notifications - MailerNotificationsPasswordChangedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsPasswordChangedNotification: nullable.NewNullableWithValue("password-changed-subject"), - MailerTemplatesPasswordChangedNotificationContent: nullable.NewNullableWithValue("password-changed-content"), - MailerNotificationsEmailChangedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsEmailChangedNotification: nullable.NewNullableWithValue("email-changed-subject"), - MailerNotificationsPhoneChangedEnabled: nullable.NewNullableWithValue(true), - MailerTemplatesPhoneChangedNotificationContent: nullable.NewNullableWithValue("phone-changed-content"), - MailerNotificationsIdentityLinkedEnabled: nullable.NewNullableWithValue(true), - MailerNotificationsIdentityUnlinkedEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsIdentityUnlinkedNotification: nullable.NewNullableWithValue("identity-unlinked-subject"), - MailerTemplatesIdentityUnlinkedNotificationContent: nullable.NewNullableWithValue("identity-unlinked-content"), - MailerNotificationsMfaFactorEnrolledEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsMfaFactorEnrolledNotification: nullable.NewNullableWithValue("mfa-enrolled-subject"), - MailerTemplatesMfaFactorEnrolledNotificationContent: nullable.NewNullableWithValue("mfa-enrolled-content"), - MailerNotificationsMfaFactorUnenrolledEnabled: nullable.NewNullableWithValue(true), - MailerSubjectsMfaFactorUnenrolledNotification: nullable.NewNullableWithValue("mfa-unenrolled-subject"), - MailerTemplatesMfaFactorUnenrolledNotificationContent: nullable.NewNullableWithValue("mfa-unenrolled-content"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local disabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Email = email{ - EnableConfirmations: false, - Template: map[string]emailTemplate{ - "invite": {}, - "confirmation": {}, - "recovery": {}, - "magic_link": {}, - "email_change": {}, - "reauthentication": {}, - }, - Notification: map[string]notification{ - "password_changed": {}, - "email_changed": {}, - "phone_changed": {}, - "identity_linked": {}, - "identity_unlinked": {}, - "mfa_factor_enrolled": {}, - "mfa_factor_unenrolled": {}, - }, - Smtp: &smtp{ - Enabled: false, - Host: "smtp.sendgrid.net", - Port: 587, - User: "apikey", - Pass: Secret{ - Value: "test-key", - SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e", - }, - AdminEmail: openapi_types.Email("admin@email.com"), - SenderName: "Admin", - }, - MaxFrequency: time.Minute, - OtpLength: 6, - OtpExpiry: 3600, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalEmailEnabled: nullable.NewNullableWithValue(false), - MailerSecureEmailChangeEnabled: nullable.NewNullableWithValue(false), - MailerAutoconfirm: nullable.NewNullableWithValue(true), - MailerOtpLength: nullable.NewNullableWithValue(6), - MailerOtpExp: 3600, - SecurityUpdatePasswordRequireReauthentication: nullable.NewNullableWithValue(false), - SmtpMaxFrequency: nullable.NewNullableWithValue(60), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestSmsDiff(t *testing.T) { - t.Run("local enabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.Sms = sms{ - EnableSignup: true, - EnableConfirmations: true, - Template: "Your code is {{ .Code }}", - TestOTP: map[string]string{"123": "456"}, - MaxFrequency: time.Minute, - Twilio: twilioConfig{ - Enabled: true, - AccountSid: "test-account", - MessageServiceSid: "test-service", - AuthToken: Secret{ - Value: "test-token", - SHA256: "c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88", - }, - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(true), - SmsAutoconfirm: nullable.NewNullableWithValue(true), - SmsMaxFrequency: nullable.NewNullableWithValue(60), - SmsOtpExp: nullable.NewNullableWithValue(3600), - SmsOtpLength: 6, - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderTwilio), - SmsTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - SmsTestOtp: nullable.NewNullableWithValue("123=456"), - SmsTestOtpValidUntil: nullable.NewNullableWithValue(time.Date(2050, 1, 1, 1, 0, 0, 0, time.UTC)), - SmsTwilioAccountSid: nullable.NewNullableWithValue("test-account"), - SmsTwilioAuthToken: nullable.NewNullableWithValue("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), - SmsTwilioContentSid: nullable.NewNullableWithValue("test-content"), - SmsTwilioMessageServiceSid: nullable.NewNullableWithValue("test-service"), - // Extra configs returned from api can be ignored - SmsMessagebirdAccessKey: nullable.NewNullableWithValue("test-messagebird-key"), - SmsMessagebirdOriginator: nullable.NewNullableWithValue("test-messagebird-originator"), - SmsTextlocalApiKey: nullable.NewNullableWithValue("test-textlocal-key"), - SmsTextlocalSender: nullable.NewNullableWithValue("test-textlocal-sencer"), - SmsTwilioVerifyAccountSid: nullable.NewNullableWithValue("test-verify-account"), - SmsTwilioVerifyAuthToken: nullable.NewNullableWithValue("test-verify-token"), - SmsTwilioVerifyMessageServiceSid: nullable.NewNullableWithValue("test-verify-service"), - SmsVonageApiKey: nullable.NewNullableWithValue("test-vonage-key"), - SmsVonageApiSecret: nullable.NewNullableWithValue("test-vonage-secret"), - SmsVonageFrom: nullable.NewNullableWithValue("test-vonage-from"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local disabled remote enabled", func(t *testing.T) { - c := newWithDefaults() - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(true), - SmsAutoconfirm: nullable.NewNullableWithValue(true), - SmsMaxFrequency: nullable.NewNullableWithValue(60), - SmsOtpExp: nullable.NewNullableWithValue(3600), - SmsOtpLength: 6, - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderTwilio), - SmsTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - SmsTestOtp: nullable.NewNullableWithValue("123=456,456=123"), - SmsTestOtpValidUntil: nullable.NewNullableWithValue(time.Date(2050, 1, 1, 1, 0, 0, 0, time.UTC)), - SmsTwilioAccountSid: nullable.NewNullableWithValue("test-account"), - SmsTwilioAuthToken: nullable.NewNullableWithValue("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), - SmsTwilioContentSid: nullable.NewNullableWithValue("test-content"), - SmsTwilioMessageServiceSid: nullable.NewNullableWithValue("test-service"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local enabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Sms = sms{ - EnableSignup: true, - EnableConfirmations: true, - Template: "Your code is {{ .Code }}", - TestOTP: map[string]string{"123": "456"}, - MaxFrequency: time.Minute, - Messagebird: messagebirdConfig{ - Enabled: true, - Originator: "test-originator", - AccessKey: Secret{ - Value: "test-access-key", - SHA256: "ab60d03fc809fb02dae838582f3ddc13d1d6cb32ffba77c4b969dd3caa496f13", - }, - }, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(false), - SmsAutoconfirm: nullable.NewNullableWithValue(false), - SmsMaxFrequency: nullable.NewNullableWithValue(0), - SmsOtpExp: nullable.NewNullableWithValue(3600), - SmsOtpLength: 6, - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderTwilio), - SmsTemplate: nullable.NewNullableWithValue(""), - SmsTwilioAccountSid: nullable.NewNullableWithValue("test-account"), - SmsTwilioAuthToken: nullable.NewNullableWithValue("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), - SmsTwilioContentSid: nullable.NewNullableWithValue("test-content"), - SmsTwilioMessageServiceSid: nullable.NewNullableWithValue("test-service"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local disabled remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.Sms = sms{ - EnableSignup: false, - EnableConfirmations: true, - Template: "Your code is {{ .Code }}", - TestOTP: map[string]string{"123": "456"}, - MaxFrequency: time.Minute, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(false), - SmsAutoconfirm: nullable.NewNullableWithValue(true), - SmsMaxFrequency: nullable.NewNullableWithValue(60), - SmsOtpExp: nullable.NewNullableWithValue(3600), - SmsOtpLength: 6, - SmsTemplate: nullable.NewNullableWithValue("Your code is {{ .Code }}"), - SmsTestOtp: nullable.NewNullableWithValue("123=456"), - SmsTestOtpValidUntil: nullable.NewNullableWithValue(time.Date(2050, 1, 1, 1, 0, 0, 0, time.UTC)), - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderMessagebird), - SmsMessagebirdAccessKey: nullable.NewNullableWithValue("test-messagebird-key"), - SmsMessagebirdOriginator: nullable.NewNullableWithValue("test-messagebird-originator"), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("enable sign up without provider", func(t *testing.T) { - // This is not a valid config because platform requires a SMS provider. - // For consistency, we handle this in config.Load and emit a warning. - c := newWithDefaults() - c.Sms.EnableSignup = true - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(false), - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderTwilio), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("enable provider without sign up", func(t *testing.T) { - c := newWithDefaults() - c.Sms.Messagebird.Enabled = true - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalPhoneEnabled: nullable.NewNullableWithValue(false), - SmsProvider: nullable.NewNullableWithValue(v1API.AuthConfigResponseOutputSmsProviderMessagebird), - SmsMessagebirdAccessKey: nullable.NewNullableWithValue(""), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestExternalDiff(t *testing.T) { - t.Run("local and remote enabled", func(t *testing.T) { - c := newWithDefaults() - c.External = map[string]provider{ - "apple": {Enabled: true}, - "azure": {Enabled: true}, - "bitbucket": {Enabled: true}, - "discord": {Enabled: true}, - "facebook": {Enabled: true}, - "figma": {Enabled: true}, - "github": {Enabled: true}, - "gitlab": {Enabled: true}, - "google": {Enabled: true}, - "kakao": {Enabled: true}, - "keycloak": {Enabled: true}, - "linkedin_oidc": {Enabled: true}, - "notion": {Enabled: true}, - "slack_oidc": {Enabled: true}, - "spotify": {Enabled: true}, - "twitch": {Enabled: true}, - "twitter": {Enabled: true}, - "x": {Enabled: true}, - "workos": {Enabled: true}, - "zoom": {Enabled: true}, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalAppleAdditionalClientIds: nullable.NewNullableWithValue(""), - ExternalAppleClientId: nullable.NewNullableWithValue(""), - ExternalAppleEnabled: nullable.NewNullableWithValue(true), - ExternalAppleSecret: nullable.NewNullableWithValue(""), - ExternalAzureClientId: nullable.NewNullableWithValue(""), - ExternalAzureEnabled: nullable.NewNullableWithValue(true), - ExternalAzureSecret: nullable.NewNullableWithValue(""), - ExternalAzureUrl: nullable.NewNullableWithValue(""), - ExternalBitbucketClientId: nullable.NewNullableWithValue(""), - ExternalBitbucketEnabled: nullable.NewNullableWithValue(true), - ExternalBitbucketSecret: nullable.NewNullableWithValue(""), - ExternalDiscordClientId: nullable.NewNullableWithValue(""), - ExternalDiscordEnabled: nullable.NewNullableWithValue(true), - ExternalDiscordSecret: nullable.NewNullableWithValue(""), - ExternalFacebookClientId: nullable.NewNullableWithValue(""), - ExternalFacebookEnabled: nullable.NewNullableWithValue(true), - ExternalFacebookSecret: nullable.NewNullableWithValue(""), - ExternalFigmaClientId: nullable.NewNullableWithValue(""), - ExternalFigmaEnabled: nullable.NewNullableWithValue(true), - ExternalFigmaSecret: nullable.NewNullableWithValue(""), - ExternalGithubClientId: nullable.NewNullableWithValue(""), - ExternalGithubEnabled: nullable.NewNullableWithValue(true), - ExternalGithubSecret: nullable.NewNullableWithValue(""), - ExternalGitlabClientId: nullable.NewNullableWithValue(""), - ExternalGitlabEnabled: nullable.NewNullableWithValue(true), - ExternalGitlabSecret: nullable.NewNullableWithValue(""), - ExternalGitlabUrl: nullable.NewNullableWithValue(""), - ExternalGoogleAdditionalClientIds: nullable.NewNullableWithValue(""), - ExternalGoogleClientId: nullable.NewNullableWithValue(""), - ExternalGoogleEnabled: nullable.NewNullableWithValue(true), - ExternalGoogleSecret: nullable.NewNullableWithValue(""), - ExternalGoogleSkipNonceCheck: nullable.NewNullableWithValue(false), - ExternalKakaoClientId: nullable.NewNullableWithValue(""), - ExternalKakaoEnabled: nullable.NewNullableWithValue(true), - ExternalKakaoSecret: nullable.NewNullableWithValue(""), - ExternalKeycloakClientId: nullable.NewNullableWithValue(""), - ExternalKeycloakEnabled: nullable.NewNullableWithValue(true), - ExternalKeycloakSecret: nullable.NewNullableWithValue(""), - ExternalKeycloakUrl: nullable.NewNullableWithValue(""), - ExternalLinkedinOidcClientId: nullable.NewNullableWithValue(""), - ExternalLinkedinOidcEnabled: nullable.NewNullableWithValue(true), - ExternalLinkedinOidcSecret: nullable.NewNullableWithValue(""), - ExternalNotionClientId: nullable.NewNullableWithValue(""), - ExternalNotionEnabled: nullable.NewNullableWithValue(true), - ExternalNotionSecret: nullable.NewNullableWithValue(""), - ExternalSlackOidcClientId: nullable.NewNullableWithValue(""), - ExternalSlackOidcEnabled: nullable.NewNullableWithValue(true), - ExternalSlackOidcSecret: nullable.NewNullableWithValue(""), - ExternalSpotifyClientId: nullable.NewNullableWithValue(""), - ExternalSpotifyEnabled: nullable.NewNullableWithValue(true), - ExternalSpotifySecret: nullable.NewNullableWithValue(""), - ExternalTwitchClientId: nullable.NewNullableWithValue(""), - ExternalTwitchEnabled: nullable.NewNullableWithValue(true), - ExternalTwitchSecret: nullable.NewNullableWithValue(""), - ExternalTwitterClientId: nullable.NewNullableWithValue(""), - ExternalTwitterEnabled: nullable.NewNullableWithValue(true), - ExternalTwitterSecret: nullable.NewNullableWithValue(""), - ExternalXClientId: nullable.NewNullableWithValue(""), - ExternalXEnabled: nullable.NewNullableWithValue(true), - ExternalXSecret: nullable.NewNullableWithValue(""), - ExternalWorkosClientId: nullable.NewNullableWithValue(""), - ExternalWorkosEnabled: nullable.NewNullableWithValue(true), - ExternalWorkosSecret: nullable.NewNullableWithValue(""), - ExternalWorkosUrl: nullable.NewNullableWithValue(""), - ExternalZoomClientId: nullable.NewNullableWithValue(""), - ExternalZoomEnabled: nullable.NewNullableWithValue(true), - ExternalZoomSecret: nullable.NewNullableWithValue(""), - // Deprecated fields should be ignored - ExternalSlackClientId: nullable.NewNullableWithValue(""), - ExternalSlackEnabled: nullable.NewNullableWithValue(true), - ExternalSlackSecret: nullable.NewNullableWithValue(""), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local enabled and disabled", func(t *testing.T) { - c := newWithDefaults() - c.External = map[string]provider{ - "apple": { - Enabled: true, - ClientId: "test-client-1,test-client-2", - Secret: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - "azure": { - Enabled: true, - ClientId: "test-client-1", - Secret: Secret{ - Value: "test-secret", - SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252", - }, - }, - "bitbucket": {}, - "discord": {}, - "facebook": {}, - "figma": {}, - "github": {}, - "gitlab": {}, - "google": { - Enabled: false, - ClientId: "test-client-2", - Secret: Secret{Value: "env(test_secret)"}, - SkipNonceCheck: false, - }, - // "kakao": {}, - "keycloak": {}, - "linkedin_oidc": {}, - "notion": {}, - "slack_oidc": {}, - "spotify": {}, - "twitch": {}, - "twitter": {}, - "x": {}, - "workos": {}, - "zoom": {}, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalAppleAdditionalClientIds: nullable.NewNullableWithValue("test-client-2"), - ExternalAppleClientId: nullable.NewNullableWithValue("test-client-1"), - ExternalAppleEnabled: nullable.NewNullableWithValue(false), - ExternalAppleSecret: nullable.NewNullableWithValue("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"), - ExternalGoogleAdditionalClientIds: nullable.NewNullableWithValue("test-client-2"), - ExternalGoogleClientId: nullable.NewNullableWithValue("test-client-1"), - ExternalGoogleEnabled: nullable.NewNullableWithValue(true), - ExternalGoogleSecret: nullable.NewNullableWithValue("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), - ExternalGoogleSkipNonceCheck: nullable.NewNullableWithValue(true), - ExternalKakaoClientId: nullable.NewNullableWithValue("test-client-2"), - ExternalKakaoEnabled: nullable.NewNullableWithValue(true), - ExternalKakaoSecret: nullable.NewNullableWithValue("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), - }) - // Check error - assert.NoError(t, err) - assertSnapshotEqual(t, diff) - }) - - t.Run("local and remote disabled", func(t *testing.T) { - c := newWithDefaults() - c.External = map[string]provider{ - "apple": {}, - "azure": {}, - "bitbucket": {}, - "discord": {}, - "facebook": {}, - "figma": {}, - "github": {}, - "gitlab": {}, - "google": {}, - "kakao": {}, - "keycloak": {}, - "linkedin_oidc": {}, - "notion": {}, - "slack_oidc": {}, - "spotify": {}, - "twitch": {}, - "twitter": {}, - "x": {}, - "workos": {}, - "zoom": {}, - } - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - ExternalAppleEnabled: nullable.NewNullableWithValue(false), - ExternalAzureEnabled: nullable.NewNullableWithValue(false), - ExternalBitbucketEnabled: nullable.NewNullableWithValue(false), - ExternalDiscordEnabled: nullable.NewNullableWithValue(false), - ExternalFacebookEnabled: nullable.NewNullableWithValue(false), - ExternalFigmaEnabled: nullable.NewNullableWithValue(false), - ExternalGithubEnabled: nullable.NewNullableWithValue(false), - ExternalGitlabEnabled: nullable.NewNullableWithValue(false), - ExternalGoogleEnabled: nullable.NewNullableWithValue(false), - ExternalGoogleSkipNonceCheck: nullable.NewNullableWithValue(false), - ExternalKakaoEnabled: nullable.NewNullableWithValue(false), - ExternalKeycloakEnabled: nullable.NewNullableWithValue(false), - ExternalLinkedinOidcEnabled: nullable.NewNullableWithValue(false), - ExternalNotionEnabled: nullable.NewNullableWithValue(false), - ExternalSlackOidcEnabled: nullable.NewNullableWithValue(false), - ExternalSpotifyEnabled: nullable.NewNullableWithValue(false), - ExternalTwitchEnabled: nullable.NewNullableWithValue(false), - ExternalTwitterEnabled: nullable.NewNullableWithValue(false), - ExternalXEnabled: nullable.NewNullableWithValue(false), - ExternalWorkosEnabled: nullable.NewNullableWithValue(false), - ExternalZoomEnabled: nullable.NewNullableWithValue(false), - // Deprecated fields should be ignored - ExternalSlackEnabled: nullable.NewNullableWithValue(false), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} - -func TestRateLimitsDiff(t *testing.T) { - t.Run("local and remote rate limits match", func(t *testing.T) { - // Setup auth with rate limits - c := newWithDefaults() - c.RateLimit.AnonymousUsers = 20 - c.RateLimit.TokenRefresh = 30 - c.RateLimit.SignInSignUps = 40 - c.RateLimit.TokenVerifications = 50 - c.RateLimit.EmailSent = 25 - c.RateLimit.SmsSent = 35 - c.Email.Smtp = &smtp{Enabled: true} - // Run test - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - RateLimitAnonymousUsers: nullable.NewNullableWithValue(20), - RateLimitTokenRefresh: nullable.NewNullableWithValue(30), - RateLimitOtp: nullable.NewNullableWithValue(40), - RateLimitVerify: nullable.NewNullableWithValue(50), - RateLimitEmailSent: nullable.NewNullableWithValue(25), - RateLimitSmsSent: nullable.NewNullableWithValue(35), - SmtpHost: nullable.NewNullableWithValue(""), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) - - t.Run("local and remote rate limits differ", func(t *testing.T) { - // Setup auth with rate limits - c := newWithDefaults() - c.RateLimit.AnonymousUsers = 20 - c.RateLimit.TokenRefresh = 30 - c.RateLimit.SignInSignUps = 40 - c.RateLimit.TokenVerifications = 50 - c.RateLimit.EmailSent = 25 - c.RateLimit.SmsSent = 35 - c.Email.Smtp = &smtp{Enabled: true} - // Run test with different remote values - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - RateLimitAnonymousUsers: nullable.NewNullableWithValue(10), // Different value - RateLimitTokenRefresh: nullable.NewNullableWithValue(30), - RateLimitOtp: nullable.NewNullableWithValue(45), // Different value - RateLimitVerify: nullable.NewNullableWithValue(50), - RateLimitEmailSent: nullable.NewNullableWithValue(15), // Different value - RateLimitSmsSent: nullable.NewNullableWithValue(55), // Different value - SmtpHost: nullable.NewNullableWithValue(""), - }) - // Check error - assert.NoError(t, err) - // Compare with snapshot - assertSnapshotEqual(t, diff) - }) - - t.Run("ignores email rate limit when smtp is disabled", func(t *testing.T) { - // Setup auth without rate limits - c := newWithDefaults() - c.RateLimit.EmailSent = 25 - // Run test with remote rate limits - diff, err := c.DiffWithRemote(v1API.AuthConfigResponseOutput{ - RateLimitEmailSent: nullable.NewNullableWithValue(15), - SmtpHost: nullable.NewNullableWithValue(""), - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, string(diff)) - }) -} diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go deleted file mode 100644 index d9a36221a4..0000000000 --- a/apps/cli-go/pkg/config/config.go +++ /dev/null @@ -1,1911 +0,0 @@ -package config - -import ( - "bytes" - "context" - _ "embed" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "io/fs" - "maps" - "net" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "regexp" - "slices" - "sort" - "strconv" - "strings" - "text/template" - "time" - - "github.com/BurntSushi/toml" - "github.com/docker/go-units" - "github.com/go-errors/errors" - "github.com/go-viper/mapstructure/v2" - "github.com/joho/godotenv" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/fetcher" - "golang.org/x/mod/semver" -) - -// Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding. -type sizeInBytes int64 - -func (s *sizeInBytes) UnmarshalText(text []byte) error { - size, err := units.RAMInBytes(string(text)) - if err == nil { - *s = sizeInBytes(size) - } - return err -} - -func (s sizeInBytes) MarshalText() (text []byte, err error) { - return []byte(units.BytesSize(float64(s))), nil -} - -type LogflareBackend string - -const ( - LogflarePostgres LogflareBackend = "postgres" - LogflareBigQuery LogflareBackend = "bigquery" -) - -func (b *LogflareBackend) UnmarshalText(text []byte) error { - allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery} - if *b = LogflareBackend(text); !slices.Contains(allowed, *b) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type AddressFamily string - -const ( - AddressIPv6 AddressFamily = "IPv6" - AddressIPv4 AddressFamily = "IPv4" -) - -func (f *AddressFamily) UnmarshalText(text []byte) error { - allowed := []AddressFamily{AddressIPv6, AddressIPv4} - if *f = AddressFamily(text); !slices.Contains(allowed, *f) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type RequestPolicy string - -const ( - PolicyPerWorker RequestPolicy = "per_worker" - PolicyOneshot RequestPolicy = "oneshot" -) - -func (p *RequestPolicy) UnmarshalText(text []byte) error { - allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot} - if *p = RequestPolicy(text); !slices.Contains(allowed, *p) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type Glob []string - -type globOptions struct { - skipEmptyGlobs bool - errorOnAllSkipped bool -} - -type GlobOption func(*globOptions) - -func WithSkipEmptyGlobs() GlobOption { - return func(o *globOptions) { - o.skipEmptyGlobs = true - } -} - -func WithErrorOnAllSkippedGlobs() GlobOption { - return func(o *globOptions) { - o.errorOnAllSkipped = true - } -} - -func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { - return g.files(fsys, nil, options...) -} - -// SQLFiles matches glob patterns and expands directory matches recursively to -// SQL files. Pattern order is preserved, and directory contents are sorted for -// deterministic application. -func (g Glob) SQLFiles(fsys fs.FS, options ...GlobOption) ([]string, error) { - return g.files(fsys, func(path string, entry fs.DirEntry) bool { - return entry.Type().IsRegular() && filepath.Ext(path) == ".sql" - }, options...) -} - -// Match the glob patterns in the given FS to get a deduplicated -// array of all migrations files to apply in the declared order. -func (g Glob) files(fsys fs.FS, expandDir func(string, fs.DirEntry) bool, options ...GlobOption) ([]string, error) { - opts := globOptions{} - for _, apply := range options { - apply(&opts) - } - var result []string - var allErrors []error - var skipped []string - set := make(map[string]struct{}) - for _, pattern := range g { - // Glob expects / as path separator on windows - matches, err := fs.Glob(fsys, filepath.ToSlash(pattern)) - if err != nil { - allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err)) - } else if len(matches) == 0 { - if opts.skipEmptyGlobs && hasGlobMeta(pattern) { - skipped = append(skipped, pattern) - continue - } - allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) - } - sort.Strings(matches) - // Remove duplicates - for _, item := range matches { - fp := filepath.ToSlash(item) - if expandDir != nil { - info, err := fs.Stat(fsys, fp) - if err != nil { - allErrors = append(allErrors, errors.Errorf("failed to stat matched file: %w", err)) - continue - } - if info.IsDir() { - files, err := walkMatchedDir(fsys, fp, expandDir) - if err != nil { - allErrors = append(allErrors, err) - continue - } - for _, file := range files { - if _, exists := set[file]; !exists { - set[file] = struct{}{} - result = append(result, file) - } - } - continue - } - } - if _, exists := set[fp]; !exists { - set[fp] = struct{}{} - result = append(result, fp) - } - } - } - if opts.errorOnAllSkipped && len(result) == 0 && len(skipped) > 0 { - for _, pattern := range skipped { - allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) - } - } - return result, errors.Join(allErrors...) -} - -func walkMatchedDir(fsys fs.FS, dir string, include func(string, fs.DirEntry) bool) ([]string, error) { - var files []string - if err := fs.WalkDir(fsys, dir, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if include(path, entry) { - files = append(files, filepath.ToSlash(path)) - } - return nil - }); err != nil { - return nil, errors.Errorf("failed to walk matched directory: %w", err) - } - sort.Strings(files) - return files, nil -} - -func hasGlobMeta(pattern string) bool { - return strings.ContainsAny(pattern, `*?[`) -} - -// We follow these rules when adding new config: -// 1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage. -// 2. Update config struct with new field and toml tag (spelled in snake_case). -// 3. Add custom field validations to LoadConfigFS function for eg. integer range checks. -// -// If you are adding new user defined secrets, such as OAuth provider secret, the default value in -// init_config.toml should be an env var substitution. For example, -// -// > secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -// -// Default values for internal configs should be added to `var Config` initializer. -type ( - // Common config fields between our "base" config and any "remote" branch specific - baseConfig struct { - ProjectId string `toml:"project_id" json:"project_id"` - Hostname string `toml:"-" json:"-"` - Api api `toml:"api" json:"api"` - Db db `toml:"db" json:"db"` - Realtime realtime `toml:"realtime" json:"realtime"` - Studio studio `toml:"studio" json:"studio"` - Inbucket inbucket `toml:"local_smtp" json:"local_smtp"` - Storage storage `toml:"storage" json:"storage"` - Auth auth `toml:"auth" json:"auth"` - EdgeRuntime edgeRuntime `toml:"edge_runtime" json:"edge_runtime"` - Functions FunctionConfig `toml:"functions" json:"functions"` - Analytics analytics `toml:"analytics" json:"analytics"` - Experimental experimental `toml:"experimental" json:"experimental"` - // Compute is parsed but never read here. The [compute] section is owned by - // the TS CLI; this field exists only so a config the TS schema accepts does - // not trip UnmarshalExact in the delegated Go child (`flags.LoadConfig`). - // The json tag is the one that matters: the decoder runs with - // dc.TagName = "json". Omitted from toml so Go never emits the section. - Compute map[string]any `toml:"-" json:"compute"` - // Local is parsed but never read here; [local] is owned by the TS CLI (see Compute). - Local map[string]any `toml:"-" json:"local"` - } - - config struct { - baseConfig - Remotes map[string]baseConfig `toml:"remotes" json:"remotes"` - } - - realtime struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - IpVersion AddressFamily `toml:"ip_version" json:"ip_version"` - MaxHeaderLength uint `toml:"max_header_length" json:"max_header_length"` - TenantId string `toml:"-" json:"-"` - EncryptionKey string `toml:"-" json:"-"` - SecretKeyBase string `toml:"-" json:"-"` - } - - studio struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - ApiUrl string `toml:"api_url" json:"api_url"` - OpenaiApiKey Secret `toml:"openai_api_key" json:"openai_api_key"` - PgmetaImage string `toml:"-" json:"-"` - } - - inbucket struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - SmtpPort uint16 `toml:"smtp_port" json:"smtp_port"` - Pop3Port uint16 `toml:"pop3_port" json:"pop3_port"` - AdminEmail string `toml:"admin_email" json:"admin_email"` - SenderName string `toml:"sender_name" json:"sender_name"` - } - - edgeRuntime struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - Policy RequestPolicy `toml:"policy" json:"policy"` - InspectorPort uint16 `toml:"inspector_port" json:"inspector_port"` - Secrets SecretsConfig `toml:"secrets" json:"secrets"` - DenoVersion uint `toml:"deno_version" json:"deno_version"` - } - - SecretsConfig map[string]Secret - FunctionConfig map[string]function - - function struct { - Enabled bool `toml:"enabled" json:"enabled"` - VerifyJWT *bool `toml:"verify_jwt" json:"verify_jwt"` - ImportMap string `toml:"import_map" json:"import_map"` - Entrypoint string `toml:"entrypoint" json:"entrypoint"` - StaticFiles Glob `toml:"static_files" json:"static_files"` - } - - analytics struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - VectorImage string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - Backend LogflareBackend `toml:"backend" json:"backend"` - GcpProjectId string `toml:"gcp_project_id" json:"gcp_project_id"` - GcpProjectNumber string `toml:"gcp_project_number" json:"gcp_project_number"` - GcpJwtPath string `toml:"gcp_jwt_path" json:"gcp_jwt_path"` - ApiKey string `toml:"-" json:"-"` - // Deprecated together with syslog - VectorPort uint16 `toml:"vector_port" json:"vector_port"` - } - - webhooks struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - PgDeltaConfig struct { - Enabled bool `toml:"enabled" json:"enabled"` - DeclarativeSchemaPath string `toml:"declarative_schema_path" json:"declarative_schema_path"` - FormatOptions string `toml:"format_options" json:"format_options"` - } - - inspect struct { - Rules []rule `toml:"rules" json:"rules"` - } - - rule struct { - Query string `toml:"query" json:"query"` - Name string `toml:"name" json:"name"` - Pass string `toml:"pass" json:"pass"` - Fail string `toml:"fail" json:"fail"` - } - - experimental struct { - OrioleDBVersion string `toml:"orioledb_version" json:"orioledb_version"` - S3Host string `toml:"s3_host" json:"s3_host"` - S3Region string `toml:"s3_region" json:"s3_region"` - S3AccessKey string `toml:"s3_access_key" json:"s3_access_key"` - S3SecretKey string `toml:"s3_secret_key" json:"s3_secret_key"` - Stack bool `toml:"-" json:"stack"` - Compute bool `toml:"-" json:"compute"` - Webhooks *webhooks `toml:"webhooks" json:"webhooks"` - PgDelta *PgDeltaConfig `toml:"pgdelta" json:"pgdelta"` - Inspect inspect `toml:"inspect" json:"inspect"` - // PgDeltaInitEnabled drives the [experimental.pgdelta] enabled value rendered - // by Eject. It is true only for the supabase init scaffold so freshly generated - // projects opt into pg-delta, and false when Eject feeds mergeDefaultValues so - // existing configs without the section keep resolving to migra (non-breaking). - PgDeltaInitEnabled bool `toml:"-" json:"-"` - } -) - -func (a *auth) Clone() auth { - copy := *a - if copy.Captcha != nil { - capt := *a.Captcha - copy.Captcha = &capt - } - if copy.Passkey != nil { - passkey := *a.Passkey - copy.Passkey = &passkey - } - if copy.Webauthn != nil { - webauthn := *a.Webauthn - webauthn.RpOrigins = slices.Clone(a.Webauthn.RpOrigins) - copy.Webauthn = &webauthn - } - copy.External = maps.Clone(a.External) - if a.Email.Smtp != nil { - mailer := *a.Email.Smtp - copy.Email.Smtp = &mailer - } - copy.Email.Template = maps.Clone(a.Email.Template) - copy.Email.Notification = maps.Clone(a.Email.Notification) - if a.Hook.MFAVerificationAttempt != nil { - hook := *a.Hook.MFAVerificationAttempt - copy.Hook.MFAVerificationAttempt = &hook - } - if a.Hook.PasswordVerificationAttempt != nil { - hook := *a.Hook.PasswordVerificationAttempt - copy.Hook.PasswordVerificationAttempt = &hook - } - if a.Hook.CustomAccessToken != nil { - hook := *a.Hook.CustomAccessToken - copy.Hook.CustomAccessToken = &hook - } - if a.Hook.SendSMS != nil { - hook := *a.Hook.SendSMS - copy.Hook.SendSMS = &hook - } - if a.Hook.SendEmail != nil { - hook := *a.Hook.SendEmail - copy.Hook.SendEmail = &hook - } - if a.Hook.BeforeUserCreated != nil { - hook := *a.Hook.BeforeUserCreated - copy.Hook.BeforeUserCreated = &hook - } - copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP) - return copy -} - -func (s *storage) Clone() storage { - copy := *s - copy.Buckets = maps.Clone(s.Buckets) - copy.AnalyticsBuckets.Buckets = maps.Clone(s.AnalyticsBuckets.Buckets) - copy.VectorBuckets.Buckets = maps.Clone(s.VectorBuckets.Buckets) - if s.ImageTransformation != nil { - img := *s.ImageTransformation - copy.ImageTransformation = &img - } - if s.S3Protocol != nil { - s3 := *s.S3Protocol - copy.S3Protocol = &s3 - } - return copy -} - -func (c *baseConfig) Clone() baseConfig { - copy := *c - copy.Db.Vault = maps.Clone(c.Db.Vault) - copy.Storage = c.Storage.Clone() - copy.EdgeRuntime.Secrets = maps.Clone(c.EdgeRuntime.Secrets) - copy.Functions = maps.Clone(c.Functions) - copy.Auth = c.Auth.Clone() - if c.Experimental.Webhooks != nil { - webhooks := *c.Experimental.Webhooks - copy.Experimental.Webhooks = &webhooks - } - if c.Experimental.PgDelta != nil { - pgDelta := *c.Experimental.PgDelta - copy.Experimental.PgDelta = &pgDelta - } - return copy -} - -type Config *config - -type ConfigEditor func(Config) - -func WithHostname(hostname string) ConfigEditor { - return func(c Config) { - c.Hostname = hostname - } -} - -func NewConfig(editors ...ConfigEditor) config { - initial := config{baseConfig: baseConfig{ - Hostname: "127.0.0.1", - Api: api{ - Image: Images.Postgrest, - KongImage: Images.Kong, - Tls: tlsKong{ - CertContent: kongCert, - KeyContent: kongKey, - }, - }, - Db: db{ - Image: Images.Pg, - Password: "postgres", - RootKey: Secret{ - Value: "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", - }, - Pooler: pooler{ - Image: Images.Supavisor, - TenantId: "pooler-dev", - EncryptionKey: "12345678901234567890123456789032", - SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - }, - Migrations: migrations{ - Enabled: true, - }, - Seed: seed{ - Enabled: true, - SqlPaths: []string{"seed.sql"}, - }, - }, - Realtime: realtime{ - Image: Images.Realtime, - IpVersion: AddressIPv4, - MaxHeaderLength: 4096, - TenantId: "realtime-dev", - EncryptionKey: "supabaserealtime", - SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - }, - Storage: storage{ - Image: Images.Storage, - ImgProxyImage: Images.ImgProxy, - S3Credentials: storageS3Credentials{ - AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", - SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", - Region: "local", - }, - }, - Auth: auth{ - Image: Images.Gotrue, - Email: email{ - Template: map[string]emailTemplate{}, - Notification: map[string]notification{}, - }, - Sms: sms{ - TestOTP: map[string]string{}, - }, - External: map[string]provider{}, - SigningKeys: []JWK{{ - KeyType: "EC", - KeyID: "b81269f1-21d8-4f2e-b719-c2240a840d90", - Use: "sig", - KeyOps: []string{"sign", "verify"}, - Algorithm: "ES256", - Extractable: cast.Ptr(true), - Curve: "P-256", - X: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - Y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - PrivateExponent: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", - }}, - }, - Inbucket: inbucket{ - Image: Images.Inbucket, - AdminEmail: "admin@email.com", - SenderName: "Admin", - }, - Studio: studio{ - Image: Images.Studio, - PgmetaImage: Images.Pgmeta, - }, - Analytics: analytics{ - Image: Images.Logflare, - VectorImage: Images.Vector, - ApiKey: "api-key", - // Defaults to bigquery for backwards compatibility with existing config.toml - Backend: LogflareBigQuery, - }, - EdgeRuntime: edgeRuntime{ - Image: Images.EdgeRuntime, - }, - }} - for _, apply := range editors { - apply(&initial) - } - return initial -} - -func (c *config) AuthExternalURL() string { - return c.Auth.GetExternalURL(c.Api.ExternalUrl) -} - -var ( - //go:embed templates/certs/kong.local.crt - kongCert []byte - //go:embed templates/certs/kong.local.key - kongKey []byte - - //go:embed templates/config.toml - initConfigEmbed string - initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed)) - - invalidProjectId = regexp.MustCompile("[^a-zA-Z0-9_.-]+") - refPattern = regexp.MustCompile(`^[a-z]{20}$`) -) - -func (c *config) Eject(w io.Writer) error { - // Defaults to current directory name as project id - if len(c.ProjectId) == 0 { - cwd, err := os.Getwd() - if err != nil { - return errors.Errorf("failed to get working directory: %w", err) - } - c.ProjectId = filepath.Base(cwd) - } - c.ProjectId = sanitizeProjectId(c.ProjectId) - // TODO: templatize all fields eventually - if err := initConfigTemplate.Option("missingkey=error").Execute(w, c); err != nil { - return errors.Errorf("failed to initialise config: %w", err) - } - return nil -} - -// Loads custom config file to struct fields tagged with toml. -func (c *config) loadFromFile(filename string, fsys fs.FS) error { - v := viper.NewWithOptions( - viper.ExperimentalBindStruct(), - viper.EnvKeyReplacer(strings.NewReplacer(".", "_")), - ) - fileConfig := viper.New() - v.SetEnvPrefix("SUPABASE") - v.AutomaticEnv() - if err := c.mergeDefaultValues(v); err != nil { - return err - } else if err := mergeFileConfig(v, filename, fsys); err != nil { - return err - } else if err := mergeFileConfig(fileConfig, filename, fsys); err != nil { - return err - } - v = normalizeDeprecatedSMTPConfig(v, fileConfig) - v = normalizeDeprecatedOrioleDBConfig(v, fileConfig) - // Find [remotes.*] block to override base config - idToName := map[string]string{} - for name, remote := range v.GetStringMap("remotes") { - projectId := v.GetString(fmt.Sprintf("remotes.%s.project_id", name)) - // Track remote project_id to check for duplication - if other, exists := idToName[projectId]; exists { - return errors.Errorf("duplicate project_id for [remotes.%s] and %s", name, other) - } - idToName[projectId] = fmt.Sprintf("[remotes.%s]", name) - if projectId == c.ProjectId { - fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId]) - if err := mergeRemoteConfig(v, remote.(map[string]any)); err != nil { - return err - } - } - } - return c.load(v) -} - -func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper { - settings := v.AllSettings() - changed := false - if fileConfig.IsSet("inbucket") { - fmt.Fprintln(os.Stderr, `WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.`) - renameDeprecatedSMTP(settings, !fileConfig.IsSet("local_smtp")) - changed = true - } - if remotes, ok := settings["remotes"].(map[string]any); ok { - for name, raw := range remotes { - remote, ok := raw.(map[string]any) - if !ok || !fileConfig.IsSet(fmt.Sprintf("remotes.%s.inbucket", name)) { - continue - } - fmt.Fprintf( - os.Stderr, - "WARN: config section [remotes.%s.inbucket] is deprecated. Please use [remotes.%s.local_smtp] instead.\n", - name, - name, - ) - renameDeprecatedSMTP(remote, !fileConfig.IsSet(fmt.Sprintf("remotes.%s.local_smtp", name))) - changed = true - } - } - if !changed { - return v - } - return rebuildViperFromSettings(settings, v) -} - -// rebuildViperFromSettings rebuilds a viper instance from rewritten settings so a -// now-removed deprecated key does not trip UnmarshalExact. It preserves the -// env-binding options from the original instance, otherwise SUPABASE_-prefixed -// env overrides bound via ExperimentalBindStruct would be silently dropped. -func rebuildViperFromSettings(settings map[string]any, original *viper.Viper) *viper.Viper { - u := viper.NewWithOptions( - viper.ExperimentalBindStruct(), - viper.EnvKeyReplacer(strings.NewReplacer(".", "_")), - ) - u.SetEnvPrefix("SUPABASE") - u.AutomaticEnv() - if err := u.MergeConfigMap(settings); err != nil { - return original - } - return u -} - -func normalizeDeprecatedOrioleDBConfig(v, fileConfig *viper.Viper) *viper.Viper { - settings := v.AllSettings() - changed := promoteDeprecatedOrioleDBVersion(settings, fileConfig, "", "experimental.orioledb_version", "db.orioledb_version") - if remotes, ok := settings["remotes"].(map[string]any); ok { - for name, raw := range remotes { - remote, ok := raw.(map[string]any) - if !ok { - continue - } - legacyKey := fmt.Sprintf("remotes.%s.experimental.orioledb_version", name) - dbKey := fmt.Sprintf("remotes.%s.db.orioledb_version", name) - if promoteDeprecatedOrioleDBVersion(remote, fileConfig, name, legacyKey, dbKey) { - changed = true - } - } - } - if !changed { - return v - } - return rebuildViperFromSettings(settings, v) -} - -// promoteDeprecatedOrioleDBVersion promotes a non-empty legacy `experimental.orioledb_version` -// onto `db.orioledb_version` in settings when the latter is absent or empty in the user's -// file (the template always writes `db.orioledb_version = ""`, so an unset value is -// indistinguishable from an explicit empty string), and warns on stderr whenever the legacy -// value is non-empty. remoteName is empty for the top-level config, or a `[remotes.*]` name -// for the warning message. -func promoteDeprecatedOrioleDBVersion(settings map[string]any, fileConfig *viper.Viper, remoteName, legacyKey, dbKey string) bool { - legacyVal := fileConfig.GetString(legacyKey) - if legacyVal == "" { - return false - } - if fileConfig.GetString(dbKey) == "" { - db, ok := settings["db"].(map[string]any) - if !ok { - db = map[string]any{} - settings["db"] = db - } - db["orioledb_version"] = legacyVal - } - if remoteName == "" { - fmt.Fprintln(os.Stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") - } else { - fmt.Fprintf( - os.Stderr, - "WARN: remotes.%s.experimental.orioledb_version is deprecated. Please use remotes.%s.db.orioledb_version instead.\n", - remoteName, - remoteName, - ) - } - return true -} - -// renameDeprecatedSMTP removes the deprecated `inbucket` key from settings. When -// promote is true (no explicit `local_smtp` is present), the inbucket values are -// deep-merged over any existing `local_smtp` defaults so a partial `[inbucket]` -// section keeps the template defaults it omits (e.g. `enabled = true`). -func renameDeprecatedSMTP(settings map[string]any, promote bool) { - inbucket, ok := settings["inbucket"] - delete(settings, "inbucket") - if !ok || !promote { - return - } - if existing, ok := settings["local_smtp"].(map[string]any); ok { - if override, ok := inbucket.(map[string]any); ok { - mergeConfigMaps(existing, override) - return - } - } - settings["local_smtp"] = inbucket -} - -// mergeConfigMaps deep-merges src into dst, overwriting leaf values while -// recursing into nested maps, mirroring viper's own config merge semantics. -func mergeConfigMaps(dst, src map[string]any) { - for k, val := range src { - if srcMap, ok := val.(map[string]any); ok { - if dstMap, ok := dst[k].(map[string]any); ok { - mergeConfigMaps(dstMap, srcMap) - continue - } - } - dst[k] = val - } -} - -func (c *config) mergeDefaultValues(v *viper.Viper) error { - v.SetConfigType("toml") - var buf bytes.Buffer - if err := c.Eject(&buf); err != nil { - return err - } else if err := v.MergeConfig(&buf); err != nil { - return errors.Errorf("failed to merge default values: %w", err) - } - return nil -} - -func mergeFileConfig(v *viper.Viper, filename string, fsys fs.FS) error { - if ext := filepath.Ext(filename); len(ext) > 0 { - v.SetConfigType(ext[1:]) - } - f, err := fsys.Open(filename) - if errors.Is(err, os.ErrNotExist) { - return nil - } else if err != nil { - return errors.Errorf("failed to read file config: %w", err) - } - defer f.Close() - if err := v.MergeConfig(f); err != nil { - return errors.Errorf("failed to merge file config: %w", err) - } - return nil -} - -func mergeRemoteConfig(v *viper.Viper, remote map[string]any) error { - u := viper.New() - if err := u.MergeConfigMap(remote); err != nil { - return errors.Errorf("failed to merge remote config: %w", err) - } - for _, k := range u.AllKeys() { - v.Set(k, u.Get(k)) - } - if key := "db.seed.enabled"; !u.IsSet(key) { - v.Set(key, false) - } - return nil -} - -func (c *config) load(v *viper.Viper) error { - // Set default values for [functions.*] when config struct is empty - for key, value := range v.GetStringMap("functions") { - if _, ok := value.(map[string]any); !ok { - // Leave validation to decode hook - continue - } - if k := fmt.Sprintf("functions.%s.enabled", key); !v.IsSet(k) { - v.Set(k, true) - } - } - // Set default values when [auth.email.smtp] is defined - if smtp := v.GetStringMap("auth.email.smtp"); len(smtp) > 0 { - if _, exists := smtp["enabled"]; !exists { - v.Set("auth.email.smtp.enabled", true) - } - } - if err := v.UnmarshalExact(c, func(dc *mapstructure.DecoderConfig) { - dc.TagName = "json" - dc.Squash = true - dc.ZeroFields = true - dc.DecodeHook = c.newDecodeHook(LoadEnvHook, ValidateFunctionsHook) - }); err != nil { - return errors.Errorf("failed to parse config: %w", err) - } - // Manually parse config to map - c.Storage.AnalyticsBuckets.Buckets = map[string]struct{}{} - for key := range v.GetStringMap("storage.analytics.buckets") { - c.Storage.AnalyticsBuckets.Buckets[key] = struct{}{} - } - c.Storage.VectorBuckets.Buckets = map[string]struct{}{} - for key := range v.GetStringMap("storage.vector.buckets") { - c.Storage.VectorBuckets.Buckets[key] = struct{}{} - } - // Convert keys to upper case: https://github.com/spf13/viper/issues/1014 - secrets := make(SecretsConfig, len(c.EdgeRuntime.Secrets)) - for k, v := range c.EdgeRuntime.Secrets { - secrets[strings.ToUpper(k)] = v - } - c.EdgeRuntime.Secrets = secrets - return nil -} - -func (c *config) newDecodeHook(fs ...mapstructure.DecodeHookFunc) mapstructure.DecodeHookFunc { - fs = append(fs, - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToIPHookFunc(), - mapstructure.StringToSliceHookFunc(","), - mapstructure.TextUnmarshallerHookFunc(), - DecryptSecretHookFunc(c.ProjectId), - ) - return mapstructure.ComposeDecodeHookFunc(fs...) -} - -func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error { - builder := NewPathBuilder(path) - // Load secrets from .env file - if err := loadNestedEnv(builder.SupabaseDirPath); err != nil { - return err - } - if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil { - return err - } - // TODO: move linked pooler connection string elsewhere - if connString, err := readLinkedPoolerUrl(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 { - c.Db.Pooler.ConnectionString = string(connString) - } - if len(c.Api.ExternalUrl) == 0 { - // Update external api url - apiUrl := url.URL{Host: net.JoinHostPort(c.Hostname, - strconv.FormatUint(uint64(c.Api.Port), 10), - )} - if c.Api.Tls.Enabled { - apiUrl.Scheme = "https" - } else { - apiUrl.Scheme = "http" - } - c.Api.ExternalUrl = apiUrl.String() - } - if len(c.Auth.ExternalUrl) == 0 { - c.Auth.ExternalUrl = c.AuthExternalURL() - } - // Set default JWT issuer if not configured - if len(c.Auth.JwtIssuer) == 0 { - c.Auth.JwtIssuer = c.Auth.ExternalUrl - } - // Update image versions - switch c.Db.MajorVersion { - case 13: - c.Db.Image = Images.Pg15 - case 14: - c.Db.Image = Images.Pg14 - case 15: - c.Db.Image = Images.Pg15 - } - if c.Db.MajorVersion > 14 { - if version, err := readTempFile(fsys, builder.PostgresVersionPath); err == nil { - // Only replace image if postgres version is above 15.1.0.55 - if i := strings.IndexByte(c.Db.Image, ':'); VersionCompare(c.Db.Image[i+1:], "15.1.0.55") >= 0 { - c.Db.Image = replaceImageTag(Images.Pg, string(version)) - } - } - if version, err := readTempFile(fsys, builder.RestVersionPath); err == nil && len(version) > 0 { - c.Api.Image = replaceImageTag(Images.Postgrest, string(version)) - } - if version, err := readTempFile(fsys, builder.GotrueVersionPath); err == nil && len(version) > 0 { - c.Auth.Image = replaceImageTag(Images.Gotrue, string(version)) - } - } - if version, err := readTempFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 { - c.Storage.Image = replaceImageTag(Images.Storage, string(version)) - } - if version, err := readTempFile(fsys, builder.StorageMigrationPath); err == nil && len(version) > 0 { - c.Storage.TargetMigration = strings.TrimSpace(string(version)) - } - if version, err := readTempFile(fsys, builder.EdgeRuntimeVersionPath); err == nil && len(version) > 0 { - c.EdgeRuntime.Image = replaceImageTag(Images.EdgeRuntime, string(version)) - } - if version, err := readTempFile(fsys, builder.PoolerVersionPath); err == nil && len(version) > 0 { - c.Db.Pooler.Image = replaceImageTag(Images.Supavisor, string(version)) - } - if version, err := readTempFile(fsys, builder.RealtimeVersionPath); err == nil && len(version) > 0 { - c.Realtime.Image = replaceImageTag(Images.Realtime, string(version)) - } - if version, err := readTempFile(fsys, builder.StudioVersionPath); err == nil && len(version) > 0 { - c.Studio.Image = replaceImageTag(Images.Studio, string(version)) - } - if version, err := readTempFile(fsys, builder.PgmetaVersionPath); err == nil && len(version) > 0 { - c.Studio.PgmetaImage = replaceImageTag(Images.Pgmeta, string(version)) - } - if version, err := readTempFile(fsys, builder.LogflareVersionPath); err == nil && len(version) > 0 { - c.Analytics.Image = replaceImageTag(Images.Logflare, string(version)) - } - // TODO: replace derived config resolution with viper decode hooks - if err := c.resolve(builder, fsys); err != nil { - return err - } - for _, apply := range overrides { - apply(c) - } - return c.Validate(fsys) -} - -func VersionCompare(a, b string) int { - var pA, pB string - if vA := strings.Split(a, "."); len(vA) > 3 { - a = strings.Join(vA[:3], ".") - pA = strings.TrimLeft(strings.Join(vA[3:], "."), "0") - } - if vB := strings.Split(b, "."); len(vB) > 3 { - b = strings.Join(vB[:3], ".") - pB = strings.TrimLeft(strings.Join(vB[3:], "."), "0") - } - if r := semver.Compare("v"+a, "v"+b); r != 0 { - return r - } - return semver.Compare("v"+pA, "v"+pB) -} - -func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error { - // Update content paths - for name, tmpl := range c.Auth.Email.Template { - // FIXME: only email template is relative to repo directory - cwd := filepath.Dir(builder.SupabaseDirPath) - if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) { - tmpl.ContentPath = filepath.Join(cwd, tmpl.ContentPath) - } - c.Auth.Email.Template[name] = tmpl - } - for name, tmpl := range c.Auth.Email.Notification { - if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) { - tmpl.ContentPath = filepath.Join(builder.SupabaseDirPath, tmpl.ContentPath) - } - c.Auth.Email.Notification[name] = tmpl - } - // Update fallback configs - for name, bucket := range c.Storage.Buckets { - if bucket.FileSizeLimit == 0 { - bucket.FileSizeLimit = c.Storage.FileSizeLimit - } - if len(bucket.ObjectsPath) > 0 && !filepath.IsAbs(bucket.ObjectsPath) { - bucket.ObjectsPath = filepath.Join(builder.SupabaseDirPath, bucket.ObjectsPath) - } - c.Storage.Buckets[name] = bucket - } - // Resolve signing keys path for cross-platform compatibility - if len(c.Auth.SigningKeysPath) > 0 && !filepath.IsAbs(c.Auth.SigningKeysPath) { - c.Auth.SigningKeysPath = filepath.Join(builder.SupabaseDirPath, c.Auth.SigningKeysPath) - } - // Resolve functions config - for slug, function := range c.Functions { - if len(function.Entrypoint) == 0 { - function.Entrypoint = filepath.Join(builder.FunctionsDir, slug, "index.ts") - } else if !filepath.IsAbs(function.Entrypoint) { - // Append supabase/ because paths in configs are specified relative to config.toml - function.Entrypoint = filepath.Join(builder.SupabaseDirPath, function.Entrypoint) - } - if len(function.ImportMap) == 0 { - functionDir := filepath.Dir(function.Entrypoint) - denoJsonPath := filepath.Join(functionDir, "deno.json") - denoJsoncPath := filepath.Join(functionDir, "deno.jsonc") - if _, err := fs.Stat(fsys, denoJsonPath); err == nil { - function.ImportMap = denoJsonPath - } else if _, err := fs.Stat(fsys, denoJsoncPath); err == nil { - function.ImportMap = denoJsoncPath - } - // Functions may not use import map so we don't set a default value - } else if !filepath.IsAbs(function.ImportMap) { - function.ImportMap = filepath.Join(builder.SupabaseDirPath, function.ImportMap) - } - for i, val := range function.StaticFiles { - if len(val) > 0 && !filepath.IsAbs(val) { - function.StaticFiles[i] = filepath.Join(builder.SupabaseDirPath, val) - } - } - c.Functions[slug] = function - } - // Resolve TLS config - if c.Api.Enabled && c.Api.Tls.Enabled { - if len(c.Api.Tls.CertPath) > 0 { - c.Api.Tls.CertPath = path.Join(builder.SupabaseDirPath, c.Api.Tls.CertPath) - } - if len(c.Api.Tls.KeyPath) > 0 { - c.Api.Tls.KeyPath = path.Join(builder.SupabaseDirPath, c.Api.Tls.KeyPath) - } - } - // Resolve database config - if c.Db.Seed.Enabled { - for i, pattern := range c.Db.Seed.SqlPaths { - if len(pattern) > 0 && !filepath.IsAbs(pattern) { - c.Db.Seed.SqlPaths[i] = path.Join(builder.SupabaseDirPath, pattern) - } - } - } - for i, pattern := range c.Db.Migrations.SchemaPaths { - if len(pattern) > 0 && !filepath.IsAbs(pattern) { - c.Db.Migrations.SchemaPaths[i] = path.Join(builder.SupabaseDirPath, pattern) - } - } - if c.Experimental.PgDelta != nil && - len(c.Experimental.PgDelta.DeclarativeSchemaPath) > 0 && - !filepath.IsAbs(c.Experimental.PgDelta.DeclarativeSchemaPath) { - c.Experimental.PgDelta.DeclarativeSchemaPath = path.Join(builder.SupabaseDirPath, c.Experimental.PgDelta.DeclarativeSchemaPath) - } - return nil -} - -func (c *config) Validate(fsys fs.FS) error { - if c.ProjectId == "" { - return errors.New("Missing required field in config: project_id") - } else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId { - fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized) - c.ProjectId = sanitized - } - // Since remote config is merged to base, we only need to validate the project_id field. - for name, remote := range c.Remotes { - if !refPattern.MatchString(remote.ProjectId) { - return errors.Errorf("Invalid config for remotes.%s.project_id. Must be like: abcdefghijklmnopqrst", name) - } - } - // Validate api config - if c.Api.AutoExposeNewTables != nil && *c.Api.AutoExposeNewTables { - fmt.Fprintln(os.Stderr, "WARN: api.auto_expose_new_tables is deprecated and will be removed on 2026-10-30. Remove the field or set it to false to adopt the new default of revoking Data API privileges on new entities in the public schema.") - } - if c.Api.Enabled { - if c.Api.Port == 0 { - return errors.New("Missing required field in config: api.port") - } - if c.Api.Tls.Enabled { - var err error - if len(c.Api.Tls.CertPath) > 0 { - if len(c.Api.Tls.KeyPath) == 0 { - return errors.New("Missing required field in config: api.tls.key_path") - } - if c.Api.Tls.CertContent, err = fs.ReadFile(fsys, c.Api.Tls.CertPath); err != nil { - return errors.Errorf("failed to read TLS cert: %w", err) - } - } - if len(c.Api.Tls.KeyPath) > 0 { - if len(c.Api.Tls.CertPath) == 0 { - return errors.New("Missing required field in config: api.tls.cert_path") - } - if c.Api.Tls.KeyContent, err = fs.ReadFile(fsys, c.Api.Tls.KeyPath); err != nil { - return errors.Errorf("failed to read TLS key: %w", err) - } - } - } - } - // Validate db config - if c.Db.Port == 0 { - return errors.New("Missing required field in config: db.port") - } - switch c.Db.MajorVersion { - case 0: - return errors.New("Missing required field in config: db.major_version") - case 12: - return errors.New("Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.") - case 13, 14: - case 15, 17: - if len(c.Db.OrioleDBVersion) > 0 { - if VersionCompare(c.Db.OrioleDBVersion, "15.1.1.13") > 0 { - c.Db.Image = fmt.Sprintf("supabase/postgres:%s-orioledb", c.Db.OrioleDBVersion) - } else { - c.Db.Image = "supabase/postgres:orioledb-" + c.Db.OrioleDBVersion - } - if err := assertEnvLoaded(c.Experimental.S3Host); err != nil { - return err - } - if err := assertEnvLoaded(c.Experimental.S3Region); err != nil { - return err - } - if err := assertEnvLoaded(c.Experimental.S3AccessKey); err != nil { - return err - } - if err := assertEnvLoaded(c.Experimental.S3SecretKey); err != nil { - return err - } - } - default: - return errors.Errorf("Failed reading config: Invalid %s: %v.", "db.major_version", c.Db.MajorVersion) - } - // Validate storage config - for name := range c.Storage.Buckets { - if err := ValidateBucketName(name); err != nil { - return err - } - } - // Validate studio config - if c.Studio.Enabled { - if c.Studio.Port == 0 { - return errors.New("Missing required field in config: studio.port") - } - if parsed, err := url.Parse(c.Studio.ApiUrl); err != nil { - return errors.Errorf("Invalid config for studio.api_url: %w", err) - } else if parsed.Host == "" || parsed.Host == c.Hostname { - c.Studio.ApiUrl = c.Api.ExternalUrl - } - } - // Validate smtp config - if c.Inbucket.Enabled { - if c.Inbucket.Port == 0 { - return errors.New("Missing required field in config: local_smtp.port") - } - } - // Validate auth config - if c.Auth.Enabled { - if c.Auth.SiteUrl == "" { - return errors.New("Missing required field in config: auth.site_url") - } - if err := assertEnvLoaded(c.Auth.SiteUrl); err != nil { - return err - } - for i, url := range c.Auth.AdditionalRedirectUrls { - if err := assertEnvLoaded(url); err != nil { - return errors.Errorf("Invalid config for auth.additional_redirect_urls[%d]: %v", i, err) - } - } - if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled { - if len(c.Auth.Captcha.Provider) == 0 { - return errors.New("Missing required field in config: auth.captcha.provider") - } - if len(c.Auth.Captcha.Secret.Value) == 0 { - return errors.Errorf("Missing required field in config: auth.captcha.secret") - } - if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil { - return err - } - } - if len(c.Auth.SigningKeysPath) > 0 { - if f, err := fsys.Open(c.Auth.SigningKeysPath); err != nil { - return errors.Errorf("failed to read signing keys: %w", err) - } else if c.Auth.SigningKeys, err = fetcher.ParseJSON[[]JWK](f); err != nil { - return errors.Errorf("failed to decode signing keys: %w", err) - } - } - if c.Auth.Passkey != nil && c.Auth.Passkey.Enabled { - if c.Auth.Webauthn == nil { - return errors.New("Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)") - } - if len(c.Auth.Webauthn.RpId) == 0 { - return errors.New("Missing required field in config: auth.webauthn.rp_id") - } - if len(c.Auth.Webauthn.RpOrigins) == 0 { - return errors.New("Missing required field in config: auth.webauthn.rp_origins") - } - if err := assertEnvLoaded(c.Auth.Webauthn.RpId); err != nil { - return errors.Errorf("Invalid config for auth.webauthn.rp_id: %v", err) - } - for i, origin := range c.Auth.Webauthn.RpOrigins { - if err := assertEnvLoaded(origin); err != nil { - return errors.Errorf("Invalid config for auth.webauthn.rp_origins[%d]: %v", i, err) - } - } - } - if err := c.Auth.Hook.validate(); err != nil { - return err - } - if err := c.Auth.MFA.validate(); err != nil { - return err - } - if err := c.Auth.Email.validate(fsys); err != nil { - return err - } - if err := c.Auth.Sms.validate(); err != nil { - return err - } - if err := c.Auth.External.validate(); err != nil { - return err - } - if err := c.Auth.ThirdParty.validate(); err != nil { - return err - } - } - if err := c.Auth.generateAPIKeys(); err != nil { - return err - } - // Validate functions config - for name := range c.Functions { - if err := ValidateFunctionSlug(name); err != nil { - return err - } - } - switch c.EdgeRuntime.DenoVersion { - case 0: - return errors.New("Missing required field in config: edge_runtime.deno_version") - case 1: - c.EdgeRuntime.Image = deno1 - case 2: - break - default: - return errors.Errorf("Failed reading config: Invalid %s: %v.", "edge_runtime.deno_version", c.EdgeRuntime.DenoVersion) - } - // Validate logflare config - if c.Analytics.Enabled { - if c.Analytics.Backend == LogflareBigQuery { - if len(c.Analytics.GcpProjectId) == 0 { - return errors.New("Missing required field in config: analytics.gcp_project_id") - } - if len(c.Analytics.GcpProjectNumber) == 0 { - return errors.New("Missing required field in config: analytics.gcp_project_number") - } - if len(c.Analytics.GcpJwtPath) == 0 { - return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path") - } - } - } - if err := c.Experimental.validate(); err != nil { - return err - } - return nil -} - -func assertEnvLoaded(s string) error { - if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 { - fmt.Fprintln(os.Stderr, "WARN: environment variable is unset:", matches[1]) - } - return nil -} - -func truncateText(text string, maxLen int) string { - if len(text) > maxLen { - return text[:maxLen] - } - return text -} - -const maxProjectIdLength = 40 - -func sanitizeProjectId(src string) string { - // A valid project ID must only contain alphanumeric and special characters _.- - sanitized := invalidProjectId.ReplaceAllString(src, "_") - // It must also start with an alphanumeric character - sanitized = strings.TrimLeft(sanitized, "_.-") - // Truncate sanitized ID to 40 characters since docker hostnames cannot exceed - // 63 characters, and we need to save space for padding supabase_*_edge_runtime. - return truncateText(sanitized, maxProjectIdLength) -} - -func loadNestedEnv(basePath string) error { - repoDir, err := os.Getwd() - if err != nil { - return errors.Errorf("failed to get repo directory: %w", err) - } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(repoDir, basePath) - } - env := os.Getenv("SUPABASE_ENV") - for cwd := basePath; cwd != filepath.Dir(repoDir); cwd = filepath.Dir(cwd) { - if err := os.Chdir(cwd); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Errorf("failed to change directory: %w", err) - } - if err := loadDefaultEnv(env); err != nil { - return err - } - } - if err := os.Chdir(repoDir); err != nil { - return errors.Errorf("failed to restore directory: %w", err) - } - return nil -} - -func loadDefaultEnv(env string) error { - if env == "" { - env = "development" - } - filenames := []string{".env." + env + ".local"} - if env != "test" { - filenames = append(filenames, ".env.local") - } - filenames = append(filenames, ".env."+env, ".env") - for _, path := range filenames { - if err := loadEnvIfExists(path); err != nil { - return err - } - } - return nil -} - -func loadEnvIfExists(path string) error { - if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) { - // If DEBUG=1, return the error as is for full debugability - if viper.GetBool("DEBUG") { - return errors.Errorf("failed to load %s: %w", path, err) - } - msg := err.Error() - switch { - case strings.HasPrefix(msg, "unexpected character"): - // Try to extract the character, fallback to generic - start := strings.Index(msg, "unexpected character \"") - if start != -1 { - start += len("unexpected character \"") - end := strings.Index(msg[start:], "\"") - if end != -1 { - char := msg[start : start+end] - return errors.Errorf("failed to parse environment file: %s (unexpected character '%s' in variable name)", path, char) - } - } - return errors.Errorf("failed to parse environment file: %s (unexpected character in variable name)", path) - case strings.HasPrefix(msg, "unterminated quoted value"): - return errors.Errorf("failed to parse environment file: %s (unterminated quoted value)", path) - // If the error message contains newlines, there is a high chance that the actual content of the - // dotenv file is being leaked. In such cases, we return a generic error to avoid unwanted leaks in the logs - case strings.Contains(msg, "\n"): - return errors.Errorf("failed to parse environment file: %s (syntax error)", path) - default: - return errors.Errorf("failed to load %s: %w", path, err) - } - } - return nil -} - -func (e *email) validate(fsys fs.FS) (err error) { - for name, tmpl := range e.Template { - if len(tmpl.ContentPath) == 0 { - if tmpl.Content != nil { - return errors.Errorf("Invalid config for auth.email.template.%s.content: please use content_path instead", name) - } - continue - } - if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil { - return errors.Errorf("Invalid config for auth.email.template.%s.content_path: %w", name, err) - } else { - tmpl.Content = cast.Ptr(string(content)) - } - e.Template[name] = tmpl - } - for name, tmpl := range e.Notification { - if !tmpl.Enabled { - continue - } - if len(tmpl.ContentPath) == 0 { - if tmpl.Content != nil { - return errors.Errorf("Invalid config for auth.email.notification.%s.content: please use content_path instead", name) - } - continue - } - if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil { - return errors.Errorf("Invalid config for auth.email.notification.%s.content_path: %w", name, err) - } else { - tmpl.Content = cast.Ptr(string(content)) - } - e.Notification[name] = tmpl - } - if e.Smtp != nil && e.Smtp.Enabled { - if len(e.Smtp.Host) == 0 { - return errors.New("Missing required field in config: auth.email.smtp.host") - } - if e.Smtp.Port == 0 { - return errors.New("Missing required field in config: auth.email.smtp.port") - } - if len(e.Smtp.User) == 0 { - return errors.New("Missing required field in config: auth.email.smtp.user") - } - if len(e.Smtp.Pass.Value) == 0 { - return errors.New("Missing required field in config: auth.email.smtp.pass") - } - if len(e.Smtp.AdminEmail) == 0 { - return errors.New("Missing required field in config: auth.email.smtp.admin_email") - } - if err := assertEnvLoaded(e.Smtp.Pass.Value); err != nil { - return err - } - } - return nil -} - -func (s *sms) validate() (err error) { - switch { - case s.Twilio.Enabled: - if len(s.Twilio.AccountSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.account_sid") - } - if len(s.Twilio.MessageServiceSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid") - } - if len(s.Twilio.AuthToken.Value) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio.auth_token") - } - if err := assertEnvLoaded(s.Twilio.AuthToken.Value); err != nil { - return err - } - case s.TwilioVerify.Enabled: - if len(s.TwilioVerify.AccountSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid") - } - if len(s.TwilioVerify.MessageServiceSid) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid") - } - if len(s.TwilioVerify.AuthToken.Value) == 0 { - return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token") - } - if err := assertEnvLoaded(s.TwilioVerify.AuthToken.Value); err != nil { - return err - } - case s.Messagebird.Enabled: - if len(s.Messagebird.Originator) == 0 { - return errors.New("Missing required field in config: auth.sms.messagebird.originator") - } - if len(s.Messagebird.AccessKey.Value) == 0 { - return errors.New("Missing required field in config: auth.sms.messagebird.access_key") - } - if err := assertEnvLoaded(s.Messagebird.AccessKey.Value); err != nil { - return err - } - case s.Textlocal.Enabled: - if len(s.Textlocal.Sender) == 0 { - return errors.New("Missing required field in config: auth.sms.textlocal.sender") - } - if len(s.Textlocal.ApiKey.Value) == 0 { - return errors.New("Missing required field in config: auth.sms.textlocal.api_key") - } - if err := assertEnvLoaded(s.Textlocal.ApiKey.Value); err != nil { - return err - } - case s.Vonage.Enabled: - if len(s.Vonage.From) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.from") - } - if len(s.Vonage.ApiKey) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.api_key") - } - if len(s.Vonage.ApiSecret.Value) == 0 { - return errors.New("Missing required field in config: auth.sms.vonage.api_secret") - } - if err := assertEnvLoaded(s.Vonage.ApiKey); err != nil { - return err - } - if err := assertEnvLoaded(s.Vonage.ApiSecret.Value); err != nil { - return err - } - case s.EnableSignup: - s.EnableSignup = false - fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login") - } - return nil -} - -func (e external) validate() (err error) { - for _, ext := range []string{"linkedin", "slack"} { - if e[ext].Enabled { - fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext) - } - delete(e, ext) - } - for ext, provider := range e { - if !provider.Enabled { - continue - } - if provider.ClientId == "" { - return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext) - } - if !slices.Contains([]string{"apple", "google"}, ext) && len(provider.Secret.Value) == 0 { - return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext) - } - if err := assertEnvLoaded(provider.ClientId); err != nil { - return err - } - if err := assertEnvLoaded(provider.Secret.Value); err != nil { - return err - } - if err := assertEnvLoaded(provider.RedirectUri); err != nil { - return err - } - if err := assertEnvLoaded(provider.Url); err != nil { - return err - } - e[ext] = provider - } - return nil -} - -func (h *hook) validate() error { - if hook := h.MFAVerificationAttempt; hook != nil { - if err := hook.validate("mfa_verification_attempt"); err != nil { - return err - } - } - if hook := h.PasswordVerificationAttempt; hook != nil { - if err := hook.validate("password_verification_attempt"); err != nil { - return err - } - } - if hook := h.CustomAccessToken; hook != nil { - if err := hook.validate("custom_access_token"); err != nil { - return err - } - } - if hook := h.SendSMS; hook != nil { - if err := hook.validate("send_sms"); err != nil { - return err - } - } - if hook := h.SendEmail; hook != nil { - if err := h.SendEmail.validate("send_email"); err != nil { - return err - } - } - if hook := h.BeforeUserCreated; hook != nil { - if err := hook.validate("before_user_created"); err != nil { - return err - } - } - return nil -} - -var hookSecretPattern = regexp.MustCompile(`^v1,whsec_[A-Za-z0-9+/=]{32,88}$`) - -func (h *hookConfig) validate(hookType string) (err error) { - // If not enabled do nothing - if !h.Enabled { - return nil - } - if h.URI == "" { - return errors.Errorf("Missing required field in config: auth.hook.%s.uri", hookType) - } - parsed, err := url.Parse(h.URI) - if err != nil { - return errors.Errorf("failed to parse template url: %w", err) - } - switch strings.ToLower(parsed.Scheme) { - case "http", "https": - if len(h.Secrets.Value) == 0 { - return errors.Errorf("Missing required field in config: auth.hook.%s.secrets", hookType) - } else if err := assertEnvLoaded(h.Secrets.Value); err != nil { - return err - } - for secret := range strings.SplitSeq(h.Secrets.Value, "|") { - if !hookSecretPattern.MatchString(secret) { - return errors.Errorf(`Invalid hook config: auth.hook.%s.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, hookType) - } - } - case "pg-functions": - if len(h.Secrets.Value) > 0 { - return errors.Errorf("Invalid hook config: auth.hook.%s.secrets is unsupported for pg-functions URI", hookType) - } - default: - return errors.Errorf("Invalid hook config: auth.hook.%s.uri should be a HTTP, HTTPS, or pg-functions URI", hookType) - } - return nil -} - -func (m *mfa) validate() error { - if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled { - return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled") - } - if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled { - return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled") - } - if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled { - return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled") - } - return nil -} - -// TODO: use field tag validator instead -var funcSlugPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) - -func ValidateFunctionSlug(slug string) error { - if !funcSlugPattern.MatchString(slug) { - return errors.Errorf(`Invalid Function name: %s. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (%s)`, slug, funcSlugPattern.String()) - } - return nil -} - -// Ref: https://github.com/supabase/storage/blob/master/src/storage/limits.ts#L59 -var bucketNamePattern = regexp.MustCompile(`^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$`) - -func ValidateBucketName(name string) error { - if !bucketNamePattern.MatchString(name) { - return errors.Errorf("Invalid Bucket name: %s. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (%s)", name, bucketNamePattern.String()) - } - return nil -} - -func (f *tpaFirebase) issuerURL() string { - return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID) -} - -func (f *tpaFirebase) validate() error { - if f.ProjectID == "" { - return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.") - } - - return nil -} - -func (a *tpaAuth0) issuerURL() string { - if a.TenantRegion != "" { - return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion) - } - - return fmt.Sprintf("https://%s.auth0.com", a.Tenant) -} - -func (a *tpaAuth0) validate() error { - if a.Tenant == "" { - return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.") - } - - return nil -} - -func (c *tpaCognito) issuerURL() string { - return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID) -} - -func (c *tpaCognito) validate() (err error) { - if c.UserPoolID == "" { - return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.") - } else if err := assertEnvLoaded(c.UserPoolID); err != nil { - return err - } - - if c.UserPoolRegion == "" { - return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.") - } else if err := assertEnvLoaded(c.UserPoolRegion); err != nil { - return err - } - - return nil -} - -var clerkDomainPattern = regexp.MustCompile("^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$") - -func (c *tpaClerk) issuerURL() string { - return fmt.Sprintf("https://%s", c.Domain) -} - -func (c *tpaClerk) validate() (err error) { - if c.Domain == "" { - return errors.New("Invalid config: auth.third_party.clerk is enabled but without a domain.") - } else if err := assertEnvLoaded(c.Domain); err != nil { - return err - } - - if !clerkDomainPattern.MatchString(c.Domain) { - return errors.New("Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.") - } - - return nil -} - -func (w *tpaWorkOs) validate() error { - if w.IssuerUrl == "" { - return errors.New("Invalid config: auth.third_party.workos is enabled but without a issuer_url.") - } - return nil -} - -func (w *tpaWorkOs) issuerURL() string { - return w.IssuerUrl -} - -func (tpa *thirdParty) validate() error { - enabled := 0 - - if tpa.Firebase.Enabled { - enabled += 1 - - if err := tpa.Firebase.validate(); err != nil { - return err - } - } - - if tpa.Auth0.Enabled { - enabled += 1 - - if err := tpa.Auth0.validate(); err != nil { - return err - } - } - - if tpa.Cognito.Enabled { - enabled += 1 - - if err := tpa.Cognito.validate(); err != nil { - return err - } - } - - if tpa.Clerk.Enabled { - enabled += 1 - - if err := tpa.Clerk.validate(); err != nil { - return err - } - } - - if tpa.WorkOs.Enabled { - enabled += 1 - - if err := tpa.WorkOs.validate(); err != nil { - return err - } - } - - if enabled > 1 { - return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.") - } - - return nil -} - -func (tpa *thirdParty) IssuerURL() string { - if tpa.Firebase.Enabled { - return tpa.Firebase.issuerURL() - } - - if tpa.Auth0.Enabled { - return tpa.Auth0.issuerURL() - } - - if tpa.Cognito.Enabled { - return tpa.Cognito.issuerURL() - } - - if tpa.Clerk.Enabled { - return tpa.Clerk.issuerURL() - } - - if tpa.WorkOs.Enabled { - return tpa.WorkOs.issuerURL() - } - - return "" -} - -type ( - remoteJWKS struct { - Keys []json.RawMessage `json:"keys"` - } - - oidcConfiguration struct { - JWKSURI string `json:"jwks_uri"` - } - - secretJWK struct { - KeyType string `json:"kty"` - KeyBase64URL string `json:"k"` - } -) - -// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth -// configs by resolving the JWKS via the OIDC discovery URL. -// It always returns a JWKS string, except when there's an error fetching. -func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { - var jwks remoteJWKS - - if issuerURL := a.ThirdParty.IssuerURL(); issuerURL != "" { - discoveryURL := issuerURL + "/.well-known/openid-configuration" - - t := &http.Client{Timeout: 10 * time.Second} - client := fetcher.NewFetcher( - discoveryURL, - fetcher.WithHTTPClient(t), - fetcher.WithExpectedStatus(http.StatusOK), - ) - - resp, err := client.Send(ctx, http.MethodGet, "", nil) - if err != nil { - return "", err - } - - oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body) - if err != nil { - return "", err - } - - if oidcConfig.JWKSURI == "" { - return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL) - } - - client = fetcher.NewFetcher( - oidcConfig.JWKSURI, - fetcher.WithHTTPClient(t), - fetcher.WithExpectedStatus(http.StatusOK), - ) - - resp, err = client.Send(ctx, http.MethodGet, "", nil) - if err != nil { - return "", err - } - - rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body) - if err != nil { - return "", err - } - - if len(rJWKS.Keys) == 0 { - return "", fmt.Errorf("auth.third_party: JWKS at URL %q as discovered from %q does not contain any JWK keys", oidcConfig.JWKSURI, discoveryURL) - } - - jwks.Keys = append(jwks.Keys, rJWKS.Keys...) - } - - // Convert each signing key to public-only version - for _, key := range a.SigningKeys { - publicKeyEncoded, err := json.Marshal(key.ToPublicJWK()) - if err != nil { - return "", errors.Errorf("failed to marshal public key: %w", err) - } - jwks.Keys = append(jwks.Keys, json.RawMessage(publicKeyEncoded)) - } - // Fallback to JWT_SECRET for backward compatibility - if len(a.SigningKeysPath) == 0 { - jwtSecret := secretJWK{ - KeyType: "oct", - KeyBase64URL: base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret.Value)), - } - - secretJWKEncoded, err := json.Marshal(&jwtSecret) - if err != nil { - return "", errors.Errorf("failed to marshal secret jwk: %w", err) - } - - jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded)) - } - - jwksEncoded, err := json.Marshal(jwks) - if err != nil { - return "", errors.Errorf("failed to marshal jwks keys: %w", err) - } - - return string(jwksEncoded), nil -} - -func (c *baseConfig) GetServiceImages() []string { - return []string{ - c.Db.Image, - c.Auth.Image, - c.Api.Image, - c.Realtime.Image, - c.Storage.Image, - c.EdgeRuntime.Image, - c.Studio.Image, - c.Studio.PgmetaImage, - c.Analytics.Image, - c.Db.Pooler.Image, - } -} - -// Retrieve the final base config to use taking into account the remotes override -// Pre: config must be loaded after setting config.ProjectID = "ref" -func (c *config) GetRemoteByProjectRef(projectRef string) (baseConfig, error) { - base := c.Clone() - for _, remote := range c.Remotes { - if remote.ProjectId == projectRef { - base.ProjectId = projectRef - return base, nil - } - } - return base, errors.Errorf("no remote found for project_id: %s", projectRef) -} - -func ToTomlBytes(config any) ([]byte, error) { - var buf bytes.Buffer - enc := toml.NewEncoder(&buf) - enc.Indent = "" - if err := enc.Encode(config); err != nil { - return nil, errors.Errorf("failed to marshal toml config: %w", err) - } - return buf.Bytes(), nil -} - -func (e *experimental) validate() error { - if e.Webhooks != nil && !e.Webhooks.Enabled { - return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined") - } - if e.PgDelta != nil && len(e.PgDelta.FormatOptions) > 0 && !json.Valid([]byte(e.PgDelta.FormatOptions)) { - return errors.Errorf("Invalid config for experimental.pgdelta.format_options: must be valid JSON") - } - return nil -} diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go deleted file mode 100644 index 6ddd051a53..0000000000 --- a/apps/cli-go/pkg/config/config_test.go +++ /dev/null @@ -1,1327 +0,0 @@ -package config - -import ( - "bytes" - _ "embed" - "fmt" - "io" - "os" - "path" - "strings" - "testing" - fs "testing/fstest" - - "github.com/BurntSushi/toml" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -//go:embed testdata/config.toml -var testInitConfigEmbed []byte - -func TestConfigParsing(t *testing.T) { - t.Run("classic config file", func(t *testing.T) { - config := NewConfig() - // Run test - var buf bytes.Buffer - require.NoError(t, config.Eject(&buf)) - file := fs.MapFile{Data: buf.Bytes()} - fsys := fs.MapFS{"config.toml": &file} - // Check error - assert.NoError(t, config.Load("config.toml", fsys)) - }) - - t.Run("optional config file", func(t *testing.T) { - config := NewConfig() - // Run test - err := config.Load("", fs.MapFS{}) - // Check error - assert.NoError(t, err) - assert.True(t, config.Storage.VectorBuckets.Enabled) - }) - - t.Run("auth external url defaults from api external url", func(t *testing.T) { - config := NewConfig() - require.NoError(t, config.Load("", fs.MapFS{})) - - assert.Equal(t, strings.TrimRight(config.Api.ExternalUrl, "/")+"/auth/v1", config.Auth.ExternalUrl) - assert.Equal(t, config.Auth.ExternalUrl, config.Auth.JwtIssuer) - }) - - t.Run("auth external url and jwt issuer preserve explicit overrides", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "config.toml": &fs.MapFile{Data: []byte(` -[api] -external_url = "https://api.example.com/" - -[auth] -site_url = "https://app.example.com" -external_url = "https://auth.example.com/custom/" -jwt_issuer = "https://issuer.example.com/custom/" -`)}, - } - - require.NoError(t, config.Load("config.toml", fsys)) - assert.Equal(t, "https://auth.example.com/custom/", config.Auth.ExternalUrl) - assert.Equal(t, "https://issuer.example.com/custom/", config.Auth.JwtIssuer) - }) - - t.Run("config file with environment variables", func(t *testing.T) { - config := NewConfig() - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, - "supabase/templates/password_changed_notification.html": &fs.MapFile{}, - "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, - "certs/my-cert.pem": &fs.MapFile{}, - "certs/my-key.pem": &fs.MapFile{}, - } - // Run test - t.Setenv("TWILIO_AUTH_TOKEN", "token") - t.Setenv("AZURE_CLIENT_ID", "hello") - t.Setenv("AZURE_SECRET", "this is cool") - t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==") - t.Setenv("SENDGRID_API_KEY", "sendgrid") - t.Setenv("AUTH_CALLBACK_URL", "http://localhost:3000/auth/callback") - assert.NoError(t, config.Load("", fsys)) - // Check error - assert.Equal(t, "hello", config.Auth.External["azure"].ClientId) - assert.Equal(t, "this is cool", config.Auth.External["azure"].Secret.Value) - assert.Equal(t, []string{ - "https://127.0.0.1:3000", - "http://localhost:3000/auth/callback", - }, config.Auth.AdditionalRedirectUrls) - }) - - t.Run("config file with environment variables fails when unset", func(t *testing.T) { - config := NewConfig() - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - } - // Run test - assert.Error(t, config.Load("", fsys)) - }) - t.Run("config file with passkey and webauthn settings", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -[auth.passkey] -enabled = true -[auth.webauthn] -rp_display_name = "Supabase CLI" -rp_id = "localhost" -rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"] -`)}, - } - // Run test - assert.NoError(t, config.Load("", fsys)) - // Check result - if assert.NotNil(t, config.Auth.Passkey) { - assert.True(t, config.Auth.Passkey.Enabled) - } - if assert.NotNil(t, config.Auth.Webauthn) { - assert.Equal(t, "Supabase CLI", config.Auth.Webauthn.RpDisplayName) - assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) - assert.Equal(t, []string{ - "http://127.0.0.1:3000", - "https://localhost:3000", - }, config.Auth.Webauthn.RpOrigins) - } - }) - - t.Run("webauthn section without passkey loads successfully", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -[auth.webauthn] -rp_display_name = "Supabase CLI" -rp_id = "localhost" -rp_origins = ["http://127.0.0.1:3000"] -`)}, - } - // Run test - assert.NoError(t, config.Load("", fsys)) - // Check result - assert.Nil(t, config.Auth.Passkey) - if assert.NotNil(t, config.Auth.Webauthn) { - assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) - } - }) - - t.Run("passkey enabled requires webauthn section", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -[auth.passkey] -enabled = true -`)}, - } - // Run test - err := config.Load("", fsys) - // Check result - assert.ErrorContains(t, err, "Missing required config section: auth.webauthn") - }) - - t.Run("passkey enabled requires rp_id", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -[auth.passkey] -enabled = true -[auth.webauthn] -rp_origins = ["http://127.0.0.1:3000"] -`)}, - } - // Run test - err := config.Load("", fsys) - // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_id") - }) - - t.Run("passkey enabled requires rp_origins", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[auth] -enabled = true -site_url = "http://127.0.0.1:3000" -[auth.passkey] -enabled = true -[auth.webauthn] -rp_id = "localhost" -`)}, - } - // Run test - err := config.Load("", fsys) - // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_origins") - }) - - t.Run("parses experimental pgdelta config", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental.pgdelta] -enabled = true -declarative_schema_path = "./db/decl" -format_options = "{\"keywordCase\":\"upper\",\"indent\":2}" -`)}, - } - - require.NoError(t, config.Load("", fsys)) - require.NotNil(t, config.Experimental.PgDelta) - assert.True(t, config.Experimental.PgDelta.Enabled) - assert.Equal(t, path.Join("supabase", "db", "decl"), config.Experimental.PgDelta.DeclarativeSchemaPath) - assert.Equal(t, `{"keywordCase":"upper","indent":2}`, config.Experimental.PgDelta.FormatOptions) - }) - - t.Run("rejects invalid experimental pgdelta format options", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental.pgdelta] -format_options = "not-json" -`)}, - } - - err := config.Load("", fsys) - assert.ErrorContains(t, err, "experimental.pgdelta.format_options") - }) - - t.Run("init scaffold opts into pgdelta", func(t *testing.T) { - config := NewConfig() - // supabase init renders the scaffold with the pg-delta opt-in flag set - config.Experimental.PgDeltaInitEnabled = true - var buf bytes.Buffer - require.NoError(t, config.Eject(&buf)) - fsys := fs.MapFS{"supabase/config.toml": &fs.MapFile{Data: buf.Bytes()}} - // Reloading the generated config resolves to pg-delta - require.NoError(t, config.Load("", fsys)) - require.NotNil(t, config.Experimental.PgDelta) - assert.True(t, config.Experimental.PgDelta.Enabled) - }) - - t.Run("absent pgdelta section falls back to migra", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental] -orioledb_version = "" -`)}, - } - - // The default ejected by mergeDefaultValues keeps pg-delta disabled, so a config - // without the section resolves to migra (PgDelta is non-nil only for version pinning). - require.NoError(t, config.Load("", fsys)) - require.NotNil(t, config.Experimental.PgDelta) - assert.False(t, config.Experimental.PgDelta.Enabled) - }) - - t.Run("explicit enabled false restores migra", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental.pgdelta] -enabled = false -`)}, - } - - require.NoError(t, config.Load("", fsys)) - require.NotNil(t, config.Experimental.PgDelta) - assert.False(t, config.Experimental.PgDelta.Enabled) - }) - - // [compute] is owned by the TS CLI, but the published JSON schema advertises it, - // so a user can hand-write it today. Every Go-delegated path goes through - // config.Load, and UnmarshalExact rejects keys baseConfig does not model — so the - // section has to at least parse here, in both base and remote position. - t.Run("accepts the TS-owned compute section", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -project_id = "test" - -[compute.api] -runtime = "node" - -[remotes.prod] -project_id = "bvikqvbczudanvggcord" - -[remotes.prod.compute.api] -instances = 3 -`)}, - } - - assert.NoError(t, config.Load("", fsys)) - }) - - t.Run("accepts the TS-owned local section", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -project_id = "test" - -[local.services] -default = { version = "bundled" } -auth = { version = "v2.197.0" } -`)}, - } - - assert.NoError(t, config.Load("", fsys)) - }) - - for _, tt := range []struct { - name string - configData string - projectID string - want bool - }{ - { - name: "base true", - configData: "[experimental]\nstack = true\n", - want: true, - }, - { - name: "base false", - configData: "[experimental]\nstack = false\n", - want: false, - }, - { - name: "remote true", - configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\nstack = true\n", - projectID: "abcdefghijklmnopqrst", - want: true, - }, - { - name: "remote false", - configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\nstack = false\n", - projectID: "abcdefghijklmnopqrst", - want: false, - }, - } { - t.Run("accepts experimental stack "+tt.name, func(t *testing.T) { - t.Setenv("SUPABASE_EXPERIMENTAL_STACK", "") - config := NewConfig() - config.ProjectId = tt.projectID - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(tt.configData)}, - } - - require.NoError(t, config.Load("", fsys)) - assert.Equal(t, tt.want, config.Experimental.Stack) - }) - } - - for _, tt := range []struct { - name string - configData string - projectID string - want bool - }{ - { - name: "base true", - configData: "[experimental]\ncompute = true\n", - want: true, - }, - { - name: "base false", - configData: "[experimental]\ncompute = false\n", - want: false, - }, - { - name: "remote true", - configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\ncompute = true\n", - projectID: "abcdefghijklmnopqrst", - want: true, - }, - { - name: "remote false", - configData: "[remotes.prod]\nproject_id = \"abcdefghijklmnopqrst\"\n[remotes.prod.experimental]\ncompute = false\n", - projectID: "abcdefghijklmnopqrst", - want: false, - }, - } { - t.Run("accepts experimental compute "+tt.name, func(t *testing.T) { - t.Setenv("SUPABASE_EXPERIMENTAL_COMPUTE", "") - config := NewConfig() - config.ProjectId = tt.projectID - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(tt.configData)}, - } - - require.NoError(t, config.Load("", fsys)) - assert.Equal(t, tt.want, config.Experimental.Compute) - }) - } - - t.Run("does not emit experimental stack or compute", func(t *testing.T) { - config := NewConfig() - config.Experimental.Stack = true - config.Experimental.Compute = true - - encodedToml, err := ToTomlBytes(config.Experimental) - require.NoError(t, err) - var encoded map[string]any - _, err = toml.Decode(string(encodedToml), &encoded) - require.NoError(t, err) - assert.NotContains(t, encoded, "stack") - assert.NotContains(t, encoded, "compute") - - var buf bytes.Buffer - require.NoError(t, config.Eject(&buf)) - var rendered map[string]any - _, err = toml.Decode(buf.String(), &rendered) - require.NoError(t, err) - experimental, ok := rendered["experimental"].(map[string]any) - if assert.True(t, ok) { - assert.NotContains(t, experimental, "stack") - assert.NotContains(t, experimental, "compute") - } - }) -} - -func TestRemoteOverride(t *testing.T) { - t.Run("load staging override", func(t *testing.T) { - config := NewConfig() - config.ProjectId = "bvikqvbczudanvggcord" - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, - "supabase/templates/password_changed_notification.html": &fs.MapFile{}, - "certs/my-cert.pem": &fs.MapFile{}, - "certs/my-key.pem": &fs.MapFile{}, - "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, - } - // Run test - t.Setenv("SUPABASE_AUTH_SITE_URL", "http://preview.com") - t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==") - assert.NoError(t, config.Load("", fsys)) - // Check error - assert.True(t, config.Db.Seed.Enabled) - assert.Equal(t, "http://preview.com", config.Auth.SiteUrl) - assert.Equal(t, []string{"image/png"}, config.Storage.Buckets["images"].AllowedMimeTypes) - }) - - t.Run("load production override", func(t *testing.T) { - config := NewConfig() - config.ProjectId = "vpefcjyosynxeiebfscx" - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, - "supabase/templates/password_changed_notification.html": &fs.MapFile{}, - "certs/my-cert.pem": &fs.MapFile{}, - "certs/my-key.pem": &fs.MapFile{}, - "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, - } - // Run test - t.Setenv("SUPABASE_AUTH_SITE_URL", "http://preview.com") - t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==") - assert.NoError(t, config.Load("", fsys)) - // Check error - assert.False(t, config.Db.Seed.Enabled) - assert.Equal(t, "http://feature-auth-branch.com/", config.Auth.SiteUrl) - assert.Equal(t, false, config.Auth.External["azure"].Enabled) - assert.Equal(t, "nope", config.Auth.External["azure"].ClientId) - }) - - t.Run("config file with remotes", func(t *testing.T) { - config := NewConfig() - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, - "supabase/templates/password_changed_notification.html": &fs.MapFile{}, - "certs/my-cert.pem": &fs.MapFile{}, - "certs/my-key.pem": &fs.MapFile{}, - "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, - } - // Run test - t.Setenv("TWILIO_AUTH_TOKEN", "token") - t.Setenv("AZURE_CLIENT_ID", "hello") - t.Setenv("AZURE_SECRET", "this is cool") - t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==") - t.Setenv("SENDGRID_API_KEY", "sendgrid") - t.Setenv("AUTH_CALLBACK_URL", "http://localhost:3000/auth/callback") - assert.NoError(t, config.Load("", fsys)) - // Check the default value in the config - assert.Equal(t, "http://127.0.0.1:3000", config.Auth.SiteUrl) - assert.Equal(t, true, config.Auth.EnableSignup) - assert.Equal(t, true, config.Auth.External["azure"].Enabled) - assert.Equal(t, []string{"image/png", "image/jpeg"}, config.Storage.Buckets["images"].AllowedMimeTypes) - // Check the values for remotes override - production, ok := config.Remotes["production"] - assert.True(t, ok) - staging, ok := config.Remotes["staging"] - assert.True(t, ok) - // Check the values for production override - assert.Equal(t, "vpefcjyosynxeiebfscx", production.ProjectId) - assert.Equal(t, "http://feature-auth-branch.com/", production.Auth.SiteUrl) - assert.Equal(t, false, production.Auth.EnableSignup) - assert.Equal(t, false, production.Auth.External["azure"].Enabled) - assert.Equal(t, "nope", production.Auth.External["azure"].ClientId) - // Check seed should be disabled by default for remote configs - assert.Equal(t, false, production.Db.Seed.Enabled) - // Check the values for the staging override - assert.Equal(t, "bvikqvbczudanvggcord", staging.ProjectId) - assert.Equal(t, []string{"image/png"}, staging.Storage.Buckets["images"].AllowedMimeTypes) - assert.Equal(t, true, staging.Db.Seed.Enabled) - }) -} - -func TestFileSizeLimitConfigParsing(t *testing.T) { - t.Run("test file size limit parsing number", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = 5000000 - `, &testConfig) - if assert.NoError(t, err) { - assert.Equal(t, sizeInBytes(5000000), testConfig.Storage.FileSizeLimit) - } - }) - - t.Run("test file size limit parsing bytes unit", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = "5MB" - `, &testConfig) - if assert.NoError(t, err) { - assert.Equal(t, sizeInBytes(5242880), testConfig.Storage.FileSizeLimit) - } - }) - - t.Run("test file size limit parsing binary bytes unit", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = "5MiB" - `, &testConfig) - if assert.NoError(t, err) { - assert.Equal(t, sizeInBytes(5242880), testConfig.Storage.FileSizeLimit) - } - }) - - t.Run("test file size limit parsing string number", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = "5000000" - `, &testConfig) - if assert.NoError(t, err) { - assert.Equal(t, sizeInBytes(5000000), testConfig.Storage.FileSizeLimit) - } - }) - - t.Run("test file size limit parsing bad datatype", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = [] - `, &testConfig) - assert.Error(t, err) - assert.Equal(t, sizeInBytes(0), testConfig.Storage.FileSizeLimit) - }) - - t.Run("test file size limit parsing bad string data", func(t *testing.T) { - var testConfig config - _, err := toml.Decode(` - [storage] - file_size_limit = "foobar" - `, &testConfig) - assert.Error(t, err) - assert.Equal(t, sizeInBytes(0), testConfig.Storage.FileSizeLimit) - }) -} - -func TestSanitizeProjectI(t *testing.T) { - // Preserves valid consecutive characters - assert.Equal(t, "abc", sanitizeProjectId("abc")) - assert.Equal(t, "a..b_c", sanitizeProjectId("a..b_c")) - // Removes leading special characters - assert.Equal(t, "abc", sanitizeProjectId("_abc")) - assert.Equal(t, "abc", sanitizeProjectId("_@abc")) - // Replaces consecutive invalid characters with a single _ - assert.Equal(t, "a_bc-", sanitizeProjectId("a@@bc-")) - // Truncates to less than 40 characters - sanitized := strings.Repeat("a", maxProjectIdLength) - assert.Equal(t, sanitized, sanitizeProjectId(sanitized+"bb")) -} - -const ( - defaultAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" - defaultServiceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" -) - -func TestSigningJWT(t *testing.T) { - t.Run("signs default anon key", func(t *testing.T) { - anonToken := CustomClaims{Role: "anon"}.NewToken() - signed, err := anonToken.SignedString([]byte(defaultJwtSecret)) - assert.NoError(t, err) - assert.Equal(t, defaultAnonKey, signed) - }) - - t.Run("signs default service_role key", func(t *testing.T) { - serviceToken := CustomClaims{Role: "service_role"}.NewToken() - signed, err := serviceToken.SignedString([]byte(defaultJwtSecret)) - assert.NoError(t, err) - assert.Equal(t, defaultServiceRoleKey, signed) - }) -} - -func TestValidateHookURI(t *testing.T) { - tests := []struct { - hookConfig - name string - errorMsg string - }{ - { - name: "valid http URL", - hookConfig: hookConfig{ - Enabled: true, - URI: "http://example.com", - Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="}, - }, - }, - { - name: "valid https URL", - hookConfig: hookConfig{ - Enabled: true, - URI: "https://example.com", - Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="}, - }, - }, - { - name: "valid pg-functions URI", - hookConfig: hookConfig{ - Enabled: true, - URI: "pg-functions://functionName", - }, - }, - { - name: "invalid URI with unsupported scheme", - hookConfig: hookConfig{ - Enabled: true, - URI: "ftp://example.com", - Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="}, - }, - errorMsg: "Invalid hook config: auth.hook.invalid URI with unsupported scheme.uri should be a HTTP, HTTPS, or pg-functions URI", - }, - { - name: "invalid URI with parsing error", - hookConfig: hookConfig{ - Enabled: true, - URI: "http://a b.com", - Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="}, - }, - errorMsg: "failed to parse template url: parse \"http://a b.com\": invalid character \" \" in host name", - }, - { - name: "valid http URL with missing secrets", - hookConfig: hookConfig{ - Enabled: true, - URI: "http://example.com", - }, - errorMsg: "Missing required field in config: auth.hook.valid http URL with missing secrets.secrets", - }, - { - name: "valid pg-functions URI with unsupported secrets", - hookConfig: hookConfig{ - Enabled: true, - URI: "pg-functions://functionName", - Secrets: Secret{Value: "test-secret"}, - }, - errorMsg: "Invalid hook config: auth.hook.valid pg-functions URI with unsupported secrets.secrets is unsupported for pg-functions URI", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.validate(tt.name) - if len(tt.errorMsg) > 0 { - assert.Error(t, err, "Expected an error for %v", tt.name) - assert.EqualError(t, err, tt.errorMsg, "Expected error message does not match for %v", tt.name) - } else { - assert.NoError(t, err, "Expected no error for %v", tt.name) - } - }) - } -} - -func TestGlobFiles(t *testing.T) { - t.Run("returns seed files matching patterns", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/seeds/seed1.sql": &fs.MapFile{Data: []byte("INSERT INTO table1 VALUES (1);")}, - "supabase/seeds/seed2.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/seed3.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/another.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/ignore.sql": &fs.MapFile{Data: []byte("INSERT INTO table3 VALUES (3);")}, - } - // Mock config patterns - g := Glob{ - "supabase/seeds/seed[12].sql", - "supabase/seeds/ano*.sql", - } - // Run test - files, err := g.Files(fsys) - // Check error - assert.NoError(t, err) - // Validate files - assert.ElementsMatch(t, []string{ - "supabase/seeds/seed1.sql", - "supabase/seeds/seed2.sql", - "supabase/seeds/another.sql", - }, files) - }) - - t.Run("returns seed files matching patterns skip duplicates", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{ - "supabase/seeds/seed1.sql": &fs.MapFile{Data: []byte("INSERT INTO table1 VALUES (1);")}, - "supabase/seeds/seed2.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/seed3.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/another.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")}, - "supabase/seeds/ignore.sql": &fs.MapFile{Data: []byte("INSERT INTO table3 VALUES (3);")}, - } - // Mock config patterns - g := Glob{ - "supabase/seeds/seed[12].sql", - "supabase/seeds/ano*.sql", - "supabase/seeds/seed*.sql", - } - // Run test - files, err := g.Files(fsys) - // Check error - assert.NoError(t, err) - // Validate files - assert.ElementsMatch(t, []string{ - "supabase/seeds/seed1.sql", - "supabase/seeds/seed2.sql", - "supabase/seeds/another.sql", - "supabase/seeds/seed3.sql", - }, files) - }) - - t.Run("returns error on invalid pattern", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Mock config patterns - g := Glob{"[*!#@D#"} - // Run test - files, err := g.Files(fsys) - // Check error - assert.ErrorIs(t, err, path.ErrBadPattern) - // The resuling seed list should be empty - assert.Empty(t, files) - }) - - t.Run("returns empty list if no files match", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Mock config patterns - g := Glob{"seeds/*.sql"} - // Run test - files, err := g.Files(fsys) - // Check error - assert.ErrorContains(t, err, "no files matched") - // Validate files - assert.Empty(t, files) - }) - - t.Run("skips empty globs when configured", func(t *testing.T) { - fsys := fs.MapFS{ - "supabase/schemas/tables/players.sql": &fs.MapFile{}, - } - g := Glob{ - "supabase/schemas/tables/*.sql", - "supabase/schemas/materialized_views/*.sql", - } - - files, err := g.Files(fsys, WithSkipEmptyGlobs()) - - assert.NoError(t, err) - assert.Equal(t, []string{"supabase/schemas/tables/players.sql"}, files) - }) - - t.Run("errors when all skipped globs are empty and configured to fail", func(t *testing.T) { - fsys := fs.MapFS{} - g := Glob{ - "supabase/schemas/tables/*.sql", - "supabase/schemas/materialized_views/*.sql", - } - - files, err := g.Files(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs()) - - assert.ErrorContains(t, err, "no files matched pattern") - assert.Empty(t, files) - }) -} - -func TestGlobSQLFiles(t *testing.T) { - t.Run("expands directory entries in declared order", func(t *testing.T) { - fsys := fs.MapFS{ - "supabase/schemas/z_function.sql": &fs.MapFile{Data: []byte("select 1;")}, - "supabase/schemas/tables/a_table.sql": &fs.MapFile{Data: []byte("select 2;")}, - "supabase/schemas/tables/nested/b_table.sql": &fs.MapFile{Data: []byte("select 3;")}, - "supabase/schemas/tables/readme.md": &fs.MapFile{Data: []byte("ignored")}, - } - g := Glob{ - "supabase/schemas/z_function.sql", - "supabase/schemas/tables", - } - - files, err := g.SQLFiles(fsys) - - assert.NoError(t, err) - assert.Equal(t, []string{ - "supabase/schemas/z_function.sql", - "supabase/schemas/tables/a_table.sql", - "supabase/schemas/tables/nested/b_table.sql", - }, files) - }) - - t.Run("deduplicates explicit files and directory matches", func(t *testing.T) { - fsys := fs.MapFS{ - "supabase/database/a.sql": &fs.MapFile{Data: []byte("select 1;")}, - "supabase/database/b.sql": &fs.MapFile{Data: []byte("select 2;")}, - } - g := Glob{ - "supabase/database/a.sql", - "supabase/database", - "supabase/database/*.sql", - } - - files, err := g.SQLFiles(fsys) - - assert.NoError(t, err) - assert.Equal(t, []string{ - "supabase/database/a.sql", - "supabase/database/b.sql", - }, files) - }) -} - -func TestLoadFunctionImportMap(t *testing.T) { - t.Run("uses deno.json as import map when present", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions.hello] - `)}, - "supabase/functions/hello/deno.json": &fs.MapFile{}, - "supabase/functions/hello/index.ts": &fs.MapFile{}, - } - // Run test - assert.NoError(t, config.Load("", fsys)) - // Check that deno.json was set as import map - assert.Equal(t, "supabase/functions/hello/deno.json", config.Functions["hello"].ImportMap) - assert.Nil(t, config.Functions["hello"].VerifyJWT) - }) - - t.Run("uses deno.jsonc as import map when present", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions.hello] - `)}, - "supabase/functions/hello/deno.jsonc": &fs.MapFile{}, - "supabase/functions/hello/index.ts": &fs.MapFile{}, - } - // Run test - assert.NoError(t, config.Load("", fsys)) - // Check that deno.jsonc was set as import map - assert.Equal(t, "supabase/functions/hello/deno.jsonc", config.Functions["hello"].ImportMap) - }) - - t.Run("config.toml takes precedence over deno.json", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions] - hello.import_map = "custom_import_map.json" - `)}, - "supabase/functions/hello/deno.json": &fs.MapFile{}, - "supabase/functions/hello/index.ts": &fs.MapFile{}, - } - // Run test - assert.NoError(t, config.Load("", fsys)) - // Check that config.toml takes precedence over deno.json - assert.Equal(t, "supabase/custom_import_map.json", config.Functions["hello"].ImportMap) - }) -} - -func TestLoadFunctionErrorMessageParsing(t *testing.T) { - t.Run("returns error for array-style function config", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [[functions]] - name = "hello" - verify_jwt = true - `)}, - } - // Run test - err := config.Load("", fsys) - // Check error contains both decode errors - assert.ErrorContains(t, err, invalidFunctionsConfigFormat) - }) - - t.Run("returns error with function slug for invalid non-existent field", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions.hello] - unknown_field = true - `)}, - } - // Run test - err := config.Load("", fsys) - // Check error contains both decode errors - assert.ErrorContains(t, err, "'functions[hello]' has invalid keys: unknown_field") - }) - - t.Run("returns error with function slug for invalid field value", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions.hello] - verify_jwt = "not-a-bool" - `)}, - } - // Run test - err := config.Load("", fsys) - // Check error contains both decode errors - assert.ErrorContains(t, err, `'functions[hello].verify_jwt' cannot parse value as 'bool': strconv.ParseBool: invalid syntax`) - }) - - t.Run("returns error for unknown function fields", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` - project_id = "bvikqvbczudanvggcord" - [functions] - name = "hello" - verify_jwt = true - `)}, - } - // Run test - err := config.Load("", fsys) - assert.ErrorContains(t, err, `'functions[name]' expected a map or struct, got "string"`) - assert.ErrorContains(t, err, `'functions[verify_jwt]' expected a map or struct, got "bool"`) - }) -} - -func TestLoadEnvIfExists(t *testing.T) { - t.Run("returns nil when file does not exist", func(t *testing.T) { - err := loadEnvIfExists("nonexistent.env") - assert.NoError(t, err) - }) - - t.Run("returns raw error when file exists but is malformed and DEBUG=1", func(t *testing.T) { - // Set DEBUG=1 - t.Setenv("DEBUG", "1") - viper.AutomaticEnv() - - // Create a temporary file with malformed content - tmpFile, err := os.CreateTemp("", "test-*.env") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - // Write malformed content - _, err = tmpFile.WriteString("[invalid]\nvalue=secret_value\n") - require.NoError(t, err) - require.NoError(t, tmpFile.Close()) - - // Test loading the malformed file - err = loadEnvIfExists(tmpFile.Name()) - // Should contain the raw error, including the secret value - assert.ErrorContains(t, err, "unexpected character") - assert.ErrorContains(t, err, "secret_value") - }) - - t.Run("returns error when file exists but is malformed invalid character", func(t *testing.T) { - // Create a temporary file with malformed content - tmpFile, err := os.CreateTemp("", "test-*.env") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - // Write malformed content - _, err = tmpFile.WriteString("[invalid]\nvalue=secret_value\n") - require.NoError(t, err) - require.NoError(t, tmpFile.Close()) - - // Test loading the malformed file - err = loadEnvIfExists(tmpFile.Name()) - assert.ErrorContains(t, err, fmt.Sprintf("failed to parse environment file: %s (unexpected character '[' in variable name)", tmpFile.Name())) - assert.NotContains(t, err.Error(), "secret_value") - }) - - t.Run("returns error when file exists but is malformed unterminated quotes", func(t *testing.T) { - // Create a temporary file with malformed content - tmpFile, err := os.CreateTemp("", "test-*.env") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - // Write malformed content - _, err = tmpFile.WriteString("value=\"secret_value\n") - require.NoError(t, err) - require.NoError(t, tmpFile.Close()) - - // Test loading the malformed file - err = loadEnvIfExists(tmpFile.Name()) - assert.ErrorContains(t, err, fmt.Sprintf("failed to parse environment file: %s (unterminated quoted value)", tmpFile.Name())) - assert.NotContains(t, err.Error(), "secret_value") - }) - - t.Run("loads valid env file successfully", func(t *testing.T) { - // Create a temporary file with valid content - tmpFile, err := os.CreateTemp("", "test-*.env") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - // Write valid content - _, err = tmpFile.WriteString("TEST_KEY=test_value\nANOTHER_KEY=another_value") - require.NoError(t, err) - require.NoError(t, tmpFile.Close()) - - // Test loading the valid file - err = loadEnvIfExists(tmpFile.Name()) - assert.NoError(t, err) - - // Verify environment variables were loaded - assert.Equal(t, "test_value", os.Getenv("TEST_KEY")) - assert.Equal(t, "another_value", os.Getenv("ANOTHER_KEY")) - - // Clean up environment variables - os.Unsetenv("TEST_KEY") - os.Unsetenv("ANOTHER_KEY") - }) -} - -func TestVersionCompare(t *testing.T) { - var testcase = []struct { - a string - b string - r int - }{ - {"15.1.0.55", "15.1.0.55", 0}, - {"15.8.1.085", "15.1.0.55", 1}, - {"15.1.0.55", "15.8.1.085", -1}, - {"17.4.1.005", "17.4.1.005", 0}, - {"17.4.1.030", "17.4.1.005", 1}, - {"17.4.1.005", "17.4.1.030", -1}, - {"15.8.1", "15.8.1", 0}, - {"17", "15.8", 1}, - {"14", "15.8", -1}, - {"oriole-17", "oriole-17", 0}, - {"17", "oriole-17", 1}, - {"oriole-17", "17", -1}, - } - - for _, tt := range testcase { - t.Run(fmt.Sprintf("%s vs %s", tt.a, tt.b), func(t *testing.T) { - result := VersionCompare(tt.a, tt.b) - assert.Equal(t, tt.r, result) - }) - } -} - -func TestDeprecatedSMTPConfig(t *testing.T) { - t.Run("maps deprecated [inbucket] to local_smtp", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[inbucket] -enabled = true -port = 12345 -`)}, - } - require.NoError(t, config.Load("", fsys)) - assert.True(t, config.Inbucket.Enabled) - assert.Equal(t, uint16(12345), config.Inbucket.Port) - }) - - t.Run("keeps template defaults for a partial [inbucket] section", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[inbucket] -port = 9999 -`)}, - } - require.NoError(t, config.Load("", fsys)) - // enabled is omitted by the user; the template default (true) must survive - // the inbucket -> local_smtp rewrite via deep merge instead of collapsing - // to the zero value. - assert.True(t, config.Inbucket.Enabled) - assert.Equal(t, uint16(9999), config.Inbucket.Port) - }) - - t.Run("prefers explicit [local_smtp] over deprecated [inbucket]", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[inbucket] -enabled = true -port = 11111 - -[local_smtp] -enabled = true -port = 22222 -`)}, - } - require.NoError(t, config.Load("", fsys)) - assert.Equal(t, uint16(22222), config.Inbucket.Port) - }) - - t.Run("normalizes deprecated [remotes.*.inbucket]", func(t *testing.T) { - config := NewConfig() - config.ProjectId = "abcdefghijklmnopqrst" - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[remotes.staging] -project_id = "abcdefghijklmnopqrst" - -[remotes.staging.inbucket] -enabled = true -port = 33333 -`)}, - } - require.NoError(t, config.Load("", fsys)) - assert.Equal(t, uint16(33333), config.Inbucket.Port) - }) - - t.Run("preserves env overrides when rewriting [inbucket]", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[inbucket] -enabled = true -port = 12345 -`)}, - } - // Env overrides are applied via ExperimentalBindStruct at unmarshal time, not - // captured by AllSettings(). Rebuilding the viper without those options while - // rewriting [inbucket] would silently drop this override. - t.Setenv("SUPABASE_AUTH_SITE_URL", "http://env-override.example/") - require.NoError(t, config.Load("", fsys)) - assert.Equal(t, "http://env-override.example/", config.Auth.SiteUrl) - assert.Equal(t, uint16(12345), config.Inbucket.Port) - }) -} - -func TestDeprecatedOrioleDBVersionConfig(t *testing.T) { - captureStderr := func(t *testing.T, run func()) string { - t.Helper() - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - defer w.Close() - orig := os.Stderr - os.Stderr = w - defer func() { os.Stderr = orig }() - run() - require.NoError(t, w.Close()) - var out bytes.Buffer - _, err = io.Copy(&out, r) - require.NoError(t, err) - return out.String() - } - - t.Run("promotes deprecated [experimental] orioledb_version to [db]", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental] -orioledb_version = "15.1.0.150" -`)}, - } - stderr := captureStderr(t, func() { - require.NoError(t, config.Load("", fsys)) - }) - assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) - assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") - }) - - t.Run("does not warn when [experimental] orioledb_version is empty", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental] -orioledb_version = "" -`)}, - } - stderr := captureStderr(t, func() { - require.NoError(t, config.Load("", fsys)) - }) - assert.Equal(t, "", config.Db.OrioleDBVersion) - assert.NotContains(t, stderr, "orioledb_version is deprecated") - }) - - t.Run("promotes deprecated [experimental] orioledb_version when [db] is explicitly empty", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental] -orioledb_version = "x" - -[db] -orioledb_version = "" -`)}, - } - stderr := captureStderr(t, func() { - require.NoError(t, config.Load("", fsys)) - }) - assert.Equal(t, "x", config.Db.OrioleDBVersion) - assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") - }) - - t.Run("prefers explicit [db] orioledb_version over deprecated [experimental]", func(t *testing.T) { - config := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental] -orioledb_version = "15.1.0.150" - -[db] -orioledb_version = "15.1.1.13" -`)}, - } - stderr := captureStderr(t, func() { - require.NoError(t, config.Load("", fsys)) - }) - assert.Equal(t, "15.1.1.13", config.Db.OrioleDBVersion) - assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") - }) - - t.Run("normalizes deprecated [remotes.*.experimental] orioledb_version", func(t *testing.T) { - config := NewConfig() - config.ProjectId = "abcdefghijklmnopqrst" - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[remotes.staging] -project_id = "abcdefghijklmnopqrst" - -[remotes.staging.experimental] -orioledb_version = "15.1.0.150" -`)}, - } - stderr := captureStderr(t, func() { - require.NoError(t, config.Load("", fsys)) - }) - assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) - assert.Contains(t, stderr, "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.") - }) -} - -func TestLoadLinkedStateFiles(t *testing.T) { - linkedFiles := fs.MapFS{ - "supabase/.temp/pooler-url": &fs.MapFile{Data: []byte("postgresql://postgres.decoy:[YOUR-PASSWORD]@pooler.example.com:6543/postgres")}, - "supabase/.temp/rest-version": &fs.MapFile{Data: []byte("v0.0.1-decoy")}, - "supabase/.temp/gotrue-version": &fs.MapFile{Data: []byte("v0.0.2-decoy")}, - "supabase/.temp/storage-version": &fs.MapFile{Data: []byte("v0.0.3-decoy")}, - "supabase/.temp/storage-migration": &fs.MapFile{Data: []byte("decoy-migration")}, - "supabase/.temp/edge-runtime-version": &fs.MapFile{Data: []byte("v0.0.4-decoy")}, - } - - t.Run("legacy mode applies the pooler url and image pins", func(t *testing.T) { - t.Setenv(StackStateEnv, "") - config := NewConfig() - require.NoError(t, config.Load("", linkedFiles)) - assert.Contains(t, config.Db.Pooler.ConnectionString, "pooler.example.com") - assert.True(t, strings.HasSuffix(config.Api.Image, ":v0.0.1-decoy"), config.Api.Image) - assert.True(t, strings.HasSuffix(config.Auth.Image, ":v0.0.2-decoy"), config.Auth.Image) - assert.True(t, strings.HasSuffix(config.Storage.Image, ":v0.0.3-decoy"), config.Storage.Image) - assert.Equal(t, "decoy-migration", config.Storage.TargetMigration) - assert.True(t, strings.HasSuffix(config.EdgeRuntime.Image, ":v0.0.4-decoy"), config.EdgeRuntime.Image) - }) - - t.Run("stack mode ignores the pooler url file and image pins", func(t *testing.T) { - t.Setenv(StackStateEnv, "1") - t.Setenv(StackPoolerUrlEnv, "") - want := NewConfig() - require.NoError(t, want.Load("", fs.MapFS{})) - config := NewConfig() - require.NoError(t, config.Load("", linkedFiles)) - assert.Equal(t, want.Db.Pooler.ConnectionString, config.Db.Pooler.ConnectionString) - assert.Equal(t, want.Api.Image, config.Api.Image) - assert.Equal(t, want.Auth.Image, config.Auth.Image) - assert.Equal(t, want.Storage.Image, config.Storage.Image) - assert.Equal(t, want.Storage.TargetMigration, config.Storage.TargetMigration) - assert.Equal(t, want.EdgeRuntime.Image, config.EdgeRuntime.Image) - }) - - t.Run("stack mode applies the pooler url from the environment", func(t *testing.T) { - const poolerUrl = "postgresql://postgres.abcdefghijklmnopqrst:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" - t.Setenv(StackStateEnv, "1") - t.Setenv(StackPoolerUrlEnv, poolerUrl) - config := NewConfig() - require.NoError(t, config.Load("", linkedFiles)) - assert.Equal(t, poolerUrl, config.Db.Pooler.ConnectionString) - }) -} diff --git a/apps/cli-go/pkg/config/constants.go b/apps/cli-go/pkg/config/constants.go deleted file mode 100644 index 93bb482f69..0000000000 --- a/apps/cli-go/pkg/config/constants.go +++ /dev/null @@ -1,77 +0,0 @@ -package config - -import ( - _ "embed" - "regexp" - - "github.com/go-errors/errors" - "github.com/go-viper/mapstructure/v2" -) - -const ( - deno1 = "supabase/edge-runtime:v1.68.4" -) - -type images struct { - Pg string `mapstructure:"pg"` - // PG13/15 and PG14 come from the Dockerfile's generated `pg15`/hand-pinned `pg14` stages - // (the single version table), not hardcoded constants. - Pg15 string `mapstructure:"pg15"` - Pg14 string `mapstructure:"pg14"` - // Append to Services when adding new dependencies below - Kong string `mapstructure:"kong"` - Inbucket string `mapstructure:"mailpit"` - Postgrest string `mapstructure:"postgrest"` - Pgmeta string `mapstructure:"pgmeta"` - Studio string `mapstructure:"studio"` - ImgProxy string `mapstructure:"imgproxy"` - EdgeRuntime string `mapstructure:"edgeruntime"` - Vector string `mapstructure:"vector"` - Supavisor string `mapstructure:"supavisor"` - Gotrue string `mapstructure:"gotrue"` - Realtime string `mapstructure:"realtime"` - Storage string `mapstructure:"storage"` - Logflare string `mapstructure:"logflare"` - // Append to Jobs when adding new dependencies below - Differ string `mapstructure:"differ"` - Migra string `mapstructure:"migra"` - PgProve string `mapstructure:"pgprove"` -} - -var ( - //go:embed templates/Dockerfile - dockerImage string - imagePattern = regexp.MustCompile(`(?i)FROM\s+([^\s]+)\s+AS\s+([^\s]+)`) - Images images -) - -func init() { - matches := imagePattern.FindAllStringSubmatch(dockerImage, -1) - result := make(map[string]string, len(matches)) - for _, m := range matches { - if len(m) == 3 { - result[m[2]] = m[1] - } - } - if err := mapstructure.Decode(result, &Images); err != nil { - panic(errors.Errorf("failed to decode images: %w", err)) - } -} - -func (s images) Services() []string { - return []string{ - s.Gotrue, - s.Realtime, - s.Storage, - s.ImgProxy, - s.Kong, - s.Inbucket, - s.Postgrest, - s.Pgmeta, - s.Studio, - s.EdgeRuntime, - s.Logflare, - s.Vector, - s.Supavisor, - } -} diff --git a/apps/cli-go/pkg/config/constants_test.go b/apps/cli-go/pkg/config/constants_test.go deleted file mode 100644 index 1f8badef39..0000000000 --- a/apps/cli-go/pkg/config/constants_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package config - -import ( - "strings" - "testing" - - "github.com/go-viper/mapstructure/v2" - "github.com/stretchr/testify/assert" -) - -func TestParseImage(t *testing.T) { - output := map[string]string{} - err := mapstructure.Decode(Images, &output) - assert.NoError(t, err) - assert.Len(t, output, strings.Count(dockerImage, "FROM")) - for _, v := range output { - assert.NotEmpty(t, v) - } -} diff --git a/apps/cli-go/pkg/config/db.go b/apps/cli-go/pkg/config/db.go deleted file mode 100644 index 821c0a554b..0000000000 --- a/apps/cli-go/pkg/config/db.go +++ /dev/null @@ -1,269 +0,0 @@ -package config - -import ( - "bytes" - "slices" - "time" - - "github.com/go-errors/errors" - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/diff" -) - -type PoolMode string - -const ( - TransactionMode PoolMode = "transaction" - SessionMode PoolMode = "session" -) - -func (m *PoolMode) UnmarshalText(text []byte) error { - allowed := []PoolMode{TransactionMode, SessionMode} - if *m = PoolMode(text); !slices.Contains(allowed, *m) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type SessionReplicationRole string - -const ( - SessionReplicationRoleOrigin SessionReplicationRole = "origin" - SessionReplicationRoleReplica SessionReplicationRole = "replica" - SessionReplicationRoleLocal SessionReplicationRole = "local" -) - -func (r *SessionReplicationRole) UnmarshalText(text []byte) error { - allowed := []SessionReplicationRole{SessionReplicationRoleOrigin, SessionReplicationRoleReplica, SessionReplicationRoleLocal} - if *r = SessionReplicationRole(text); !slices.Contains(allowed, *r) { - return errors.Errorf("must be one of %v", allowed) - } - return nil -} - -type ( - settings struct { - EffectiveCacheSize *string `toml:"effective_cache_size" json:"effective_cache_size"` - LogicalDecodingWorkMem *string `toml:"logical_decoding_work_mem" json:"logical_decoding_work_mem"` - MaintenanceWorkMem *string `toml:"maintenance_work_mem" json:"maintenance_work_mem"` - MaxConnections *uint `toml:"max_connections" json:"max_connections"` - MaxLocksPerTransaction *uint `toml:"max_locks_per_transaction" json:"max_locks_per_transaction"` - MaxParallelMaintenanceWorkers *uint `toml:"max_parallel_maintenance_workers" json:"max_parallel_maintenance_workers"` - MaxParallelWorkers *uint `toml:"max_parallel_workers" json:"max_parallel_workers"` - MaxParallelWorkersPerGather *uint `toml:"max_parallel_workers_per_gather" json:"max_parallel_workers_per_gather"` - MaxReplicationSlots *uint `toml:"max_replication_slots" json:"max_replication_slots"` - MaxSlotWalKeepSize *string `toml:"max_slot_wal_keep_size" json:"max_slot_wal_keep_size"` - MaxStandbyArchiveDelay *string `toml:"max_standby_archive_delay" json:"max_standby_archive_delay"` - MaxStandbyStreamingDelay *string `toml:"max_standby_streaming_delay" json:"max_standby_streaming_delay"` - MaxWalSize *string `toml:"max_wal_size" json:"max_wal_size"` - MaxWalSenders *uint `toml:"max_wal_senders" json:"max_wal_senders"` - MaxWorkerProcesses *uint `toml:"max_worker_processes" json:"max_worker_processes"` - SessionReplicationRole *SessionReplicationRole `toml:"session_replication_role" json:"session_replication_role"` - SharedBuffers *string `toml:"shared_buffers" json:"shared_buffers"` - StatementTimeout *string `toml:"statement_timeout" json:"statement_timeout"` - TrackActivityQuerySize *string `toml:"track_activity_query_size" json:"track_activity_query_size"` - TrackCommitTimestamp *bool `toml:"track_commit_timestamp" json:"track_commit_timestamp"` - WalKeepSize *string `toml:"wal_keep_size" json:"wal_keep_size"` - WalSenderTimeout *string `toml:"wal_sender_timeout" json:"wal_sender_timeout"` - WorkMem *string `toml:"work_mem" json:"work_mem"` - } - - networkRestrictions struct { - Enabled bool `toml:"enabled" json:"enabled"` - AllowedCidrs []string `toml:"allowed_cidrs" json:"allowed_cidrs"` - AllowedCidrsV6 []string `toml:"allowed_cidrs_v6" json:"allowed_cidrs_v6"` - } - - sslEnforcement struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - db struct { - Image string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - ShadowPort uint16 `toml:"shadow_port" json:"shadow_port"` - HealthTimeout time.Duration `toml:"health_timeout" json:"health_timeout"` - MajorVersion uint `toml:"major_version" json:"major_version"` - OrioleDBVersion string `toml:"orioledb_version" json:"orioledb_version"` - Password string `toml:"-" json:"-"` - RootKey Secret `toml:"root_key" json:"root_key"` - Pooler pooler `toml:"pooler" json:"pooler"` - Migrations migrations `toml:"migrations" json:"migrations"` - Seed seed `toml:"seed" json:"seed"` - Settings settings `toml:"settings" json:"settings"` - NetworkRestrictions networkRestrictions `toml:"network_restrictions" json:"network_restrictions"` - SslEnforcement *sslEnforcement `toml:"ssl_enforcement" json:"ssl_enforcement"` - Vault map[string]Secret `toml:"vault" json:"vault"` - } - - migrations struct { - Enabled bool `toml:"enabled" json:"enabled"` - SchemaPaths Glob `toml:"schema_paths" json:"schema_paths"` - } - - seed struct { - Enabled bool `toml:"enabled" json:"enabled"` - SqlPaths Glob `toml:"sql_paths" json:"sql_paths"` - } - - pooler struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - Port uint16 `toml:"port" json:"port"` - PoolMode PoolMode `toml:"pool_mode" json:"pool_mode"` - DefaultPoolSize uint `toml:"default_pool_size" json:"default_pool_size"` - MaxClientConn uint `toml:"max_client_conn" json:"max_client_conn"` - ConnectionString string `toml:"-" json:"-"` - TenantId string `toml:"-" json:"-"` - EncryptionKey string `toml:"-" json:"-"` - SecretKeyBase string `toml:"-" json:"-"` - } -) - -func (a *settings) ToUpdatePostgresConfigBody() v1API.UpdatePostgresConfigBody { - body := v1API.UpdatePostgresConfigBody{} - - // Parameters that require restart - body.MaxConnections = cast.UintToIntPtr(a.MaxConnections) - body.MaxWorkerProcesses = cast.UintToIntPtr(a.MaxWorkerProcesses) - body.MaxParallelWorkers = cast.UintToIntPtr(a.MaxParallelWorkers) - body.MaxWalSenders = cast.UintToIntPtr(a.MaxWalSenders) - body.MaxReplicationSlots = cast.UintToIntPtr(a.MaxReplicationSlots) - body.SharedBuffers = a.SharedBuffers - - // Parameters that can be changed without restart - body.EffectiveCacheSize = a.EffectiveCacheSize - body.LogicalDecodingWorkMem = a.LogicalDecodingWorkMem - body.MaintenanceWorkMem = a.MaintenanceWorkMem - body.MaxLocksPerTransaction = cast.UintToIntPtr(a.MaxLocksPerTransaction) - body.MaxParallelMaintenanceWorkers = cast.UintToIntPtr(a.MaxParallelMaintenanceWorkers) - body.MaxParallelWorkersPerGather = cast.UintToIntPtr(a.MaxParallelWorkersPerGather) - body.MaxSlotWalKeepSize = a.MaxSlotWalKeepSize - body.MaxStandbyArchiveDelay = a.MaxStandbyArchiveDelay - body.MaxStandbyStreamingDelay = a.MaxStandbyStreamingDelay - body.MaxWalSize = a.MaxWalSize - body.SessionReplicationRole = (*v1API.UpdatePostgresConfigBodySessionReplicationRole)(a.SessionReplicationRole) - body.StatementTimeout = a.StatementTimeout - body.TrackActivityQuerySize = a.TrackActivityQuerySize - body.TrackCommitTimestamp = a.TrackCommitTimestamp - body.WalKeepSize = a.WalKeepSize - body.WalSenderTimeout = a.WalSenderTimeout - body.WorkMem = a.WorkMem - return body -} - -func (a *settings) FromRemotePostgresConfig(remoteConfig v1API.PostgresConfigResponseOutput) { - a.EffectiveCacheSize = remoteConfig.EffectiveCacheSize - a.LogicalDecodingWorkMem = remoteConfig.LogicalDecodingWorkMem - a.MaintenanceWorkMem = remoteConfig.MaintenanceWorkMem - a.MaxConnections = cast.IntToUintPtr(remoteConfig.MaxConnections) - a.MaxLocksPerTransaction = cast.IntToUintPtr(remoteConfig.MaxLocksPerTransaction) - a.MaxParallelMaintenanceWorkers = cast.IntToUintPtr(remoteConfig.MaxParallelMaintenanceWorkers) - a.MaxParallelWorkers = cast.IntToUintPtr(remoteConfig.MaxParallelWorkers) - a.MaxParallelWorkersPerGather = cast.IntToUintPtr(remoteConfig.MaxParallelWorkersPerGather) - a.MaxReplicationSlots = cast.IntToUintPtr(remoteConfig.MaxReplicationSlots) - a.MaxSlotWalKeepSize = remoteConfig.MaxSlotWalKeepSize - a.MaxStandbyArchiveDelay = remoteConfig.MaxStandbyArchiveDelay - a.MaxStandbyStreamingDelay = remoteConfig.MaxStandbyStreamingDelay - a.MaxWalSenders = cast.IntToUintPtr(remoteConfig.MaxWalSenders) - a.MaxWalSize = remoteConfig.MaxWalSize - a.MaxWorkerProcesses = cast.IntToUintPtr(remoteConfig.MaxWorkerProcesses) - a.SessionReplicationRole = (*SessionReplicationRole)(remoteConfig.SessionReplicationRole) - a.SharedBuffers = remoteConfig.SharedBuffers - a.StatementTimeout = remoteConfig.StatementTimeout - a.TrackActivityQuerySize = remoteConfig.TrackActivityQuerySize - a.TrackCommitTimestamp = remoteConfig.TrackCommitTimestamp - a.WalKeepSize = remoteConfig.WalKeepSize - a.WalSenderTimeout = remoteConfig.WalSenderTimeout - a.WorkMem = remoteConfig.WorkMem -} - -const pgConfHeader = "\n# supabase [db.settings] configuration\n" - -// create a valid string to append to /etc/postgresql/postgresql.conf -func (a *settings) ToPostgresConfig() string { - // Assuming postgres settings is always a flat struct, we can serialise - // using toml, then replace double quotes with single. - data, _ := ToTomlBytes(*a) - body := bytes.ReplaceAll(data, []byte{'"'}, []byte{'\''}) - return pgConfHeader + string(body) -} - -func (a *settings) DiffWithRemote(remoteConfig v1API.PostgresConfigResponseOutput) ([]byte, error) { - copy := *a - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - copy.FromRemotePostgresConfig(remoteConfig) - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[db.settings]", remoteCompare, "local[db.settings]", currentValue), nil -} - -func (n networkRestrictions) ToUpdateNetworkRestrictionsBody() v1API.V1UpdateNetworkRestrictionsJSONRequestBody { - body := v1API.V1UpdateNetworkRestrictionsJSONRequestBody{ - DbAllowedCidrs: &n.AllowedCidrs, - DbAllowedCidrsV6: &n.AllowedCidrsV6, - } - return body -} - -func (n *networkRestrictions) FromRemoteNetworkRestrictions(remoteConfig v1API.NetworkRestrictionsResponseOutput) { - if !n.Enabled { - return - } - if remoteConfig.Config.DbAllowedCidrs != nil { - n.AllowedCidrs = *remoteConfig.Config.DbAllowedCidrs - } - if remoteConfig.Config.DbAllowedCidrsV6 != nil { - n.AllowedCidrsV6 = *remoteConfig.Config.DbAllowedCidrsV6 - } -} - -func (n *networkRestrictions) DiffWithRemote(remoteConfig v1API.NetworkRestrictionsResponseOutput) ([]byte, error) { - copy := *n - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - copy.FromRemoteNetworkRestrictions(remoteConfig) - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[db.network_restrictions]", remoteCompare, "local[db.network_restrictions]", currentValue), nil -} - -func (s sslEnforcement) ToUpdateSslEnforcementBody() v1API.V1UpdateSslEnforcementConfigJSONRequestBody { - body := v1API.V1UpdateSslEnforcementConfigJSONRequestBody{} - body.RequestedConfig.Database = s.Enabled - return body -} - -func (s *sslEnforcement) FromRemoteSslEnforcement(remoteConfig v1API.SslEnforcementResponseOutput) { - if s == nil { - return - } - s.Enabled = remoteConfig.CurrentConfig.Database -} - -func (s *sslEnforcement) DiffWithRemote(remoteConfig v1API.SslEnforcementResponseOutput) ([]byte, error) { - copy := *s - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - copy.FromRemoteSslEnforcement(remoteConfig) - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[db.ssl_enforcement]", remoteCompare, "local[db.ssl_enforcement]", currentValue), nil -} diff --git a/apps/cli-go/pkg/config/db_test.go b/apps/cli-go/pkg/config/db_test.go deleted file mode 100644 index 2e23ec9563..0000000000 --- a/apps/cli-go/pkg/config/db_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" -) - -func TestDbSettingsToUpdatePostgresConfigBody(t *testing.T) { - t.Run("converts all fields correctly", func(t *testing.T) { - db := &db{ - Settings: settings{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(uint(100)), - SharedBuffers: cast.Ptr("1GB"), - StatementTimeout: cast.Ptr("30s"), - SessionReplicationRole: cast.Ptr(SessionReplicationRoleReplica), - }, - } - - body := db.Settings.ToUpdatePostgresConfigBody() - - assert.Equal(t, "4GB", *body.EffectiveCacheSize) - assert.Equal(t, 100, *body.MaxConnections) - assert.Equal(t, "1GB", *body.SharedBuffers) - assert.Equal(t, "30s", *body.StatementTimeout) - assert.Equal(t, v1API.UpdatePostgresConfigBodySessionReplicationRoleReplica, *body.SessionReplicationRole) - }) - - t.Run("handles empty fields", func(t *testing.T) { - db := &db{} - - body := db.Settings.ToUpdatePostgresConfigBody() - - assert.Nil(t, body.EffectiveCacheSize) - assert.Nil(t, body.MaxConnections) - assert.Nil(t, body.SharedBuffers) - assert.Nil(t, body.StatementTimeout) - assert.Nil(t, body.SessionReplicationRole) - }) -} - -func TestDbSettingsDiff(t *testing.T) { - t.Run("detects differences", func(t *testing.T) { - db := &db{ - Settings: settings{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(uint(100)), - SharedBuffers: cast.Ptr("1GB"), - }, - } - - remoteConfig := v1API.PostgresConfigResponseOutput{ - EffectiveCacheSize: cast.Ptr("8GB"), - MaxConnections: cast.Ptr(200), - SharedBuffers: cast.Ptr("2GB"), - } - - diff, err := db.Settings.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) - - t.Run("handles no differences", func(t *testing.T) { - db := &db{ - Settings: settings{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(uint(100)), - SharedBuffers: cast.Ptr("1GB"), - }, - } - - remoteConfig := v1API.PostgresConfigResponseOutput{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(100), - SharedBuffers: cast.Ptr("1GB"), - } - - diff, err := db.Settings.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assert.Empty(t, diff) - }) - - t.Run("handles multiple schemas and search paths with spaces", func(t *testing.T) { - db := &db{ - Settings: settings{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(uint(100)), - SharedBuffers: cast.Ptr("1GB"), - }, - } - - remoteConfig := v1API.PostgresConfigResponseOutput{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(100), - SharedBuffers: cast.Ptr("1GB"), - } - - diff, err := db.Settings.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assert.Empty(t, diff) - }) - - t.Run("handles api disabled on remote side", func(t *testing.T) { - db := &db{ - Settings: settings{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(uint(100)), - SharedBuffers: cast.Ptr("1GB"), - }, - } - - remoteConfig := v1API.PostgresConfigResponseOutput{ - // All fields are nil to simulate disabled API - } - - diff, err := db.Settings.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) - - t.Run("handles api disabled on local side", func(t *testing.T) { - db := &db{ - Settings: settings{ - // All fields are nil to simulate disabled API - }, - } - - remoteConfig := v1API.PostgresConfigResponseOutput{ - EffectiveCacheSize: cast.Ptr("4GB"), - MaxConnections: cast.Ptr(100), - SharedBuffers: cast.Ptr("1GB"), - } - - diff, err := db.Settings.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - - assertSnapshotEqual(t, diff) - }) -} - -func TestSettingsToPostgresConfig(t *testing.T) { - t.Run("Only set values should appear", func(t *testing.T) { - settings := settings{ - MaxConnections: cast.Ptr(uint(100)), - MaxLocksPerTransaction: cast.Ptr(uint(64)), - SharedBuffers: cast.Ptr("128MB"), - WorkMem: cast.Ptr("4MB"), - } - got := settings.ToPostgresConfig() - - assert.Contains(t, got, "max_connections = 100") - assert.Contains(t, got, "max_locks_per_transaction = 64") - assert.Contains(t, got, "shared_buffers = '128MB'") - assert.Contains(t, got, "work_mem = '4MB'") - - assert.NotContains(t, got, "effective_cache_size") - assert.NotContains(t, got, "maintenance_work_mem") - assert.NotContains(t, got, "max_parallel_workers") - }) - - t.Run("SessionReplicationRole should be handled correctly", func(t *testing.T) { - settings := settings{ - SessionReplicationRole: cast.Ptr(SessionReplicationRoleOrigin), - } - got := settings.ToPostgresConfig() - - assert.Contains(t, got, "session_replication_role = 'origin'") - }) - - t.Run("Empty settings should result in empty string", func(t *testing.T) { - settings := settings{} - got := settings.ToPostgresConfig() - - assert.Equal(t, got, "\n# supabase [db.settings] configuration\n") - assert.NotContains(t, got, "=") - }) -} - -func TestNetworkRestrictionsFromRemote(t *testing.T) { - t.Run("converts from remote config with restrictions", func(t *testing.T) { - ipv4Cidrs := []string{"192.168.1.0/24"} - ipv6Cidrs := []string{"2001:db8::/32"} - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &ipv4Cidrs - remoteConfig.Config.DbAllowedCidrsV6 = &ipv6Cidrs - nr := networkRestrictions{Enabled: true} - nr.FromRemoteNetworkRestrictions(remoteConfig) - assert.ElementsMatch(t, ipv4Cidrs, nr.AllowedCidrs) - assert.ElementsMatch(t, ipv6Cidrs, nr.AllowedCidrsV6) - }) - - t.Run("converts from remote config with allow all", func(t *testing.T) { - ipv4Cidrs := []string{"0.0.0.0/0"} - ipv6Cidrs := []string{"::/0"} - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &ipv4Cidrs - remoteConfig.Config.DbAllowedCidrsV6 = &ipv6Cidrs - nr := networkRestrictions{Enabled: true} - nr.FromRemoteNetworkRestrictions(remoteConfig) - assert.ElementsMatch(t, ipv4Cidrs, nr.AllowedCidrs) - assert.ElementsMatch(t, ipv6Cidrs, nr.AllowedCidrsV6) - }) - - t.Run("ignores locally disabled network restrictions", func(t *testing.T) { - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &[]string{"192.168.1.0/24"} - remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"2001:db8::/32"} - nr := networkRestrictions{} - nr.FromRemoteNetworkRestrictions(remoteConfig) - assert.False(t, nr.Enabled) - assert.Empty(t, nr.AllowedCidrs) - assert.Empty(t, nr.AllowedCidrsV6) - }) -} - -func TestNetworkRestrictionsDiff(t *testing.T) { - t.Run("detects differences", func(t *testing.T) { - local := networkRestrictions{ - Enabled: true, - AllowedCidrs: []string{"192.168.1.0/24"}, - AllowedCidrsV6: []string{"2001:db8::/32"}, - } - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &[]string{"10.0.0.0/8"} - remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"fd00::/8"} - diff, err := local.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - assert.Contains(t, string(diff), "-allowed_cidrs = [\"10.0.0.0/8\"]") - assert.Contains(t, string(diff), "+allowed_cidrs = [\"192.168.1.0/24\"]") - assert.Contains(t, string(diff), "-allowed_cidrs_v6 = [\"fd00::/8\"]") - assert.Contains(t, string(diff), "+allowed_cidrs_v6 = [\"2001:db8::/32\"]") - }) - - t.Run("no differences", func(t *testing.T) { - local := networkRestrictions{ - Enabled: true, - AllowedCidrs: []string{"192.168.1.0/24"}, - AllowedCidrsV6: []string{"2001:db8::/32"}, - } - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &local.AllowedCidrs - remoteConfig.Config.DbAllowedCidrsV6 = &local.AllowedCidrsV6 - diff, err := local.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - assert.Empty(t, diff) - }) - - t.Run("both have no restrictions - disabled vs allow all", func(t *testing.T) { - local := networkRestrictions{} - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &[]string{"0.0.0.0/0"} - remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"::/0"} - diff, err := local.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - assert.Empty(t, diff) - }) - - t.Run("local disallow all, remote allow all", func(t *testing.T) { - local := networkRestrictions{ - Enabled: true, - AllowedCidrs: []string{}, - AllowedCidrsV6: []string{}, - } - remoteConfig := v1API.NetworkRestrictionsResponseOutput{} - remoteConfig.Config.DbAllowedCidrs = &[]string{"0.0.0.0/0"} - remoteConfig.Config.DbAllowedCidrsV6 = &[]string{"::/0"} - diff, err := local.DiffWithRemote(remoteConfig) - assert.NoError(t, err) - assert.Contains(t, string(diff), "-allowed_cidrs = [\"0.0.0.0/0\"]") - assert.Contains(t, string(diff), "+allowed_cidrs = []") - assert.Contains(t, string(diff), "-allowed_cidrs_v6 = [\"::/0\"]") - assert.Contains(t, string(diff), "+allowed_cidrs_v6 = []") - }) -} diff --git a/apps/cli-go/pkg/config/decode_hooks.go b/apps/cli-go/pkg/config/decode_hooks.go deleted file mode 100644 index 265ec872e9..0000000000 --- a/apps/cli-go/pkg/config/decode_hooks.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "os" - "reflect" - "regexp" - - "github.com/go-errors/errors" -) - -var envPattern = regexp.MustCompile(`^env\((.*)\)$`) - -// LoadEnvHook is a mapstructure decode hook that loads environment variables -// from strings formatted as env(VAR_NAME). -func LoadEnvHook(f reflect.Kind, t reflect.Kind, data any) (any, error) { - if f != reflect.String { - return data, nil - } - value := data.(string) - if matches := envPattern.FindStringSubmatch(value); len(matches) > 1 { - if env := os.Getenv(matches[1]); len(env) > 0 { - value = env - } - } - return value, nil -} - -const invalidFunctionsConfigFormat = `Invalid functions config format. Functions should be configured as: - -[functions.] -field = value - -Example: -[functions.hello] -verify_jwt = true` - -// ValidateFunctionsHook is a mapstructure decode hook that validates the functions config format. -func ValidateFunctionsHook(f reflect.Type, t reflect.Type, data any) (any, error) { - // Only handle FunctionConfig type - if t != reflect.TypeOf(FunctionConfig{}) { - return data, nil - } - - // Check if source is not a map - if f.Kind() != reflect.Map { - return nil, errors.New(invalidFunctionsConfigFormat) - } - - return data, nil -} diff --git a/apps/cli-go/pkg/config/secret.go b/apps/cli-go/pkg/config/secret.go deleted file mode 100644 index 8fa7f811b9..0000000000 --- a/apps/cli-go/pkg/config/secret.go +++ /dev/null @@ -1,109 +0,0 @@ -package config - -import ( - "encoding/base64" - "os" - "reflect" - "strings" - - ecies "github.com/ecies/go/v2" - "github.com/go-errors/errors" - "github.com/go-viper/mapstructure/v2" -) - -type Secret struct { - Value string - SHA256 string -} - -const HASHED_PREFIX = "hash:" - -func (s Secret) MarshalText() (text []byte, err error) { - if len(s.SHA256) == 0 { - return []byte{}, nil - } - return []byte(HASHED_PREFIX + s.SHA256), nil -} - -const ENCRYPTED_PREFIX = "encrypted:" - -func (s *Secret) Decrypt(keys []string) error { - if !strings.HasPrefix(s.Value, ENCRYPTED_PREFIX) { - return nil - } - if len(keys) == 0 { - return errors.New(errMissingKey) - } - var err error - for _, k := range keys { - // Use the first private key that successfully decrypts the secret - if s.Value, err = decrypt(k, s.Value); err == nil { - break - } - } - // If we didn't break, none of the keys worked - return err -} - -var errMissingKey = errors.New("missing private key") - -// Decrypt secret values following dotenvx convention: -// https://github.com/dotenvx/dotenvx/blob/main/src/lib/helpers/decryptKeyValue.js -func decrypt(key, value string) (string, error) { - if len(key) == 0 { - return value, errors.New(errMissingKey) - } - // Verify private key exists - privateKey, err := ecies.NewPrivateKeyFromHex(key) - if err != nil { - return value, errors.Errorf("failed to hex decode private key: %w", err) - } - // Verify ciphertext is base64 encoded - encoded := value[len(ENCRYPTED_PREFIX):] - ciphertext, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return value, errors.Errorf("failed to base64 decode secret: %w", err) - } - // Return decrypted value - plaintext, err := ecies.Decrypt(privateKey, ciphertext) - if err != nil { - return value, errors.Errorf("failed to decrypt secret: %w", err) - } - return string(plaintext), nil -} - -const PRIVATE_KEY_PREFIX = "DOTENV_PRIVATE_KEY" - -func DecryptSecretHookFunc(hashKey string) mapstructure.DecodeHookFunc { - // Get all env vars and filter for private keys - var privateKeys []string - for _, env := range os.Environ() { - kv := strings.SplitN(env, "=", 2) - if kv[0] == PRIVATE_KEY_PREFIX || strings.HasPrefix(kv[0], PRIVATE_KEY_PREFIX+"_") { - privateKeys = append(privateKeys, strToArr(kv[1])...) - } - } - return func(f reflect.Type, t reflect.Type, data any) (any, error) { - if f.Kind() != reflect.String { - return data, nil - } - var result Secret - if t != reflect.TypeOf(result) { - return data, nil - } - result.Value = data.(string) - if len(result.Value) == 0 { - return result, nil - } - // Unloaded env() references should be returned verbatim without hashing - if envPattern.MatchString(result.Value) { - return result, nil - } - if err := result.Decrypt(privateKeys); err != nil { - return result, err - } - // Decrypted values should be hashed - result.SHA256 = sha256Hmac(hashKey, result.Value) - return result, nil - } -} diff --git a/apps/cli-go/pkg/config/secret_test.go b/apps/cli-go/pkg/config/secret_test.go deleted file mode 100644 index a83d07666b..0000000000 --- a/apps/cli-go/pkg/config/secret_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDecryptSecret(t *testing.T) { - key := "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb" - value := "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/" - - t.Run("decrypts secret value", func(t *testing.T) { - // Run test - plaintext, err := decrypt(key, value) - // Check error - assert.NoError(t, err) - assert.Equal(t, "value", plaintext) - }) - - t.Run("throws error on missing key", func(t *testing.T) { - // Run test - plaintext, err := decrypt("", value) - // Check error - assert.ErrorIs(t, err, errMissingKey) - assert.Equal(t, value, plaintext) - }) - - t.Run("throws error on non-hex key", func(t *testing.T) { - // Run test - plaintext, err := decrypt("invalid", value) - // Check error - assert.ErrorContains(t, err, "failed to hex decode private key: cannot decode hex string") - assert.Equal(t, value, plaintext) - }) - - t.Run("throws error on non-base64 value", func(t *testing.T) { - // Run test - plaintext, err := decrypt(key, "encrypted:invalid") - // Check error - assert.ErrorContains(t, err, "failed to base64 decode secret: illegal base64 data at input byte 4") - assert.Equal(t, "encrypted:invalid", plaintext) - }) - - t.Run("throws error on empty ciphertext", func(t *testing.T) { - // Run test - plaintext, err := decrypt(key, "encrypted:") - // Check error - assert.ErrorContains(t, err, "failed to decrypt secret: invalid length of message") - assert.Equal(t, "encrypted:", plaintext) - }) -} diff --git a/apps/cli-go/pkg/config/storage.go b/apps/cli-go/pkg/config/storage.go deleted file mode 100644 index a5d39ef080..0000000000 --- a/apps/cli-go/pkg/config/storage.go +++ /dev/null @@ -1,170 +0,0 @@ -package config - -import ( - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/diff" -) - -type ( - storage struct { - Enabled bool `toml:"enabled" json:"enabled"` - Image string `toml:"-" json:"-"` - TargetMigration string `toml:"-" json:"-"` - ImgProxyImage string `toml:"-" json:"-"` - FileSizeLimit sizeInBytes `toml:"file_size_limit" json:"file_size_limit"` - ImageTransformation *imageTransformation `toml:"image_transformation" json:"image_transformation"` - S3Protocol *s3Protocol `toml:"s3_protocol" json:"s3_protocol"` - S3Credentials storageS3Credentials `toml:"-" json:"-"` - Buckets BucketConfig `toml:"buckets" json:"buckets"` - AnalyticsBuckets analyticsBuckets `toml:"analytics" json:"analytics"` - VectorBuckets vectorBuckets `toml:"vector" json:"vector"` - } - - imageTransformation struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - analyticsBuckets struct { - Enabled bool `toml:"enabled" json:"enabled"` - MaxNamespaces uint `toml:"max_namespaces" json:"max_namespaces"` - MaxTables uint `toml:"max_tables" json:"max_tables"` - MaxCatalogs uint `toml:"max_catalogs" json:"max_catalogs"` - Buckets map[string]struct{} `toml:"buckets" json:"buckets"` - } - - vectorBuckets struct { - Enabled bool `toml:"enabled" json:"enabled"` - MaxBuckets uint `toml:"max_buckets" json:"max_buckets"` - MaxIndexes uint `toml:"max_indexes" json:"max_indexes"` - Buckets map[string]struct{} `toml:"buckets" json:"buckets"` - } - - s3Protocol struct { - Enabled bool `toml:"enabled" json:"enabled"` - } - - storageS3Credentials struct { - AccessKeyId string `toml:"-" json:"-"` - SecretAccessKey string `toml:"-" json:"-"` - Region string `toml:"-" json:"-"` - } - - BucketConfig map[string]bucket - - bucket struct { - Public *bool `toml:"public" json:"public"` - FileSizeLimit sizeInBytes `toml:"file_size_limit" json:"file_size_limit"` - AllowedMimeTypes []string `toml:"allowed_mime_types" json:"allowed_mime_types"` - ObjectsPath string `toml:"objects_path" json:"objects_path"` - } -) - -func (s *storage) ToUpdateStorageConfigBody() v1API.UpdateStorageConfigBody { - body := v1API.UpdateStorageConfigBody{ - FileSizeLimit: cast.Ptr(int64(s.FileSizeLimit)), - Features: &struct { - IcebergCatalog *struct { - Enabled bool `json:"enabled"` - MaxCatalogs int `json:"maxCatalogs"` - MaxNamespaces int `json:"maxNamespaces"` - MaxTables int `json:"maxTables"` - } `json:"icebergCatalog,omitempty"` - ImageTransformation *struct { - Enabled bool `json:"enabled"` - } `json:"imageTransformation,omitempty"` - PurgeCache *struct { - Enabled bool `json:"enabled"` - } `json:"purgeCache,omitempty"` - S3Protocol *struct { - Enabled bool `json:"enabled"` - } `json:"s3Protocol,omitempty"` - VectorBuckets *struct { - Enabled bool `json:"enabled"` - MaxBuckets int `json:"maxBuckets"` - MaxIndexes int `json:"maxIndexes"` - } `json:"vectorBuckets,omitempty"` - }{}, - } - // When local config is not set, we assume platform defaults should not change - if s.ImageTransformation != nil { - body.Features.ImageTransformation = &struct { - Enabled bool `json:"enabled"` - }{ - Enabled: s.ImageTransformation.Enabled, - } - } - // Disabling analytics and vector buckets means leaving platform values unchanged - if s.AnalyticsBuckets.Enabled { - body.Features.IcebergCatalog = &struct { - Enabled bool `json:"enabled"` - MaxCatalogs int `json:"maxCatalogs"` - MaxNamespaces int `json:"maxNamespaces"` - MaxTables int `json:"maxTables"` - }{ - Enabled: true, - MaxNamespaces: cast.UintToInt(s.AnalyticsBuckets.MaxNamespaces), - MaxTables: cast.UintToInt(s.AnalyticsBuckets.MaxTables), - MaxCatalogs: cast.UintToInt(s.AnalyticsBuckets.MaxCatalogs), - } - } - if s.VectorBuckets.Enabled { - body.Features.VectorBuckets = &struct { - Enabled bool `json:"enabled"` - MaxBuckets int `json:"maxBuckets"` - MaxIndexes int `json:"maxIndexes"` - }{ - Enabled: true, - MaxBuckets: cast.UintToInt(s.VectorBuckets.MaxBuckets), - MaxIndexes: cast.UintToInt(s.VectorBuckets.MaxIndexes), - } - } - if s.S3Protocol != nil { - body.Features.S3Protocol = &struct { - Enabled bool `json:"enabled"` - }{ - Enabled: s.S3Protocol.Enabled, - } - } - return body -} - -func (s *storage) FromRemoteStorageConfig(remoteConfig v1API.StorageConfigResponseOutput) { - s.FileSizeLimit = sizeInBytes(remoteConfig.FileSizeLimit) - if value, err := remoteConfig.MigrationVersion.Get(); err == nil { - s.TargetMigration = value - } - // When local config is not set, we assume platform defaults should not change - if s.ImageTransformation != nil { - s.ImageTransformation.Enabled = remoteConfig.Features.ImageTransformation.Enabled - } - if s.AnalyticsBuckets.Enabled { - s.AnalyticsBuckets.Enabled = remoteConfig.Features.IcebergCatalog.Enabled - s.AnalyticsBuckets.MaxNamespaces = cast.IntToUint(remoteConfig.Features.IcebergCatalog.MaxNamespaces) - s.AnalyticsBuckets.MaxTables = cast.IntToUint(remoteConfig.Features.IcebergCatalog.MaxTables) - s.AnalyticsBuckets.MaxCatalogs = cast.IntToUint(remoteConfig.Features.IcebergCatalog.MaxCatalogs) - } - if s.VectorBuckets.Enabled { - s.VectorBuckets.Enabled = remoteConfig.Features.VectorBuckets.Enabled - s.VectorBuckets.MaxBuckets = cast.IntToUint(remoteConfig.Features.VectorBuckets.MaxBuckets) - s.VectorBuckets.MaxIndexes = cast.IntToUint(remoteConfig.Features.VectorBuckets.MaxIndexes) - } - if s.S3Protocol != nil { - s.S3Protocol.Enabled = remoteConfig.Features.S3Protocol.Enabled - } -} - -func (s *storage) DiffWithRemote(remoteConfig v1API.StorageConfigResponseOutput) ([]byte, error) { - copy := s.Clone() - // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - copy.FromRemoteStorageConfig(remoteConfig) - remoteCompare, err := ToTomlBytes(copy) - if err != nil { - return nil, err - } - return diff.Diff("remote[storage]", remoteCompare, "local[storage]", currentValue), nil -} diff --git a/apps/cli-go/pkg/config/storage_test.go b/apps/cli-go/pkg/config/storage_test.go deleted file mode 100644 index 859bb9f413..0000000000 --- a/apps/cli-go/pkg/config/storage_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import ( - "testing" - - "github.com/oapi-codegen/nullable" - "github.com/stretchr/testify/assert" - v1API "github.com/supabase/cli/pkg/api" -) - -// TargetMigration carries `toml:"-" json:"-"`, so DiffWithRemote cannot observe -// it: the clone it compares is serialized to TOML. These assert the mapping -// directly instead. -func TestStorageMigrationVersionMapping(t *testing.T) { - t.Run("adopts the remote migration version when the platform reports one", func(t *testing.T) { - s := storage{TargetMigration: "local-metadata"} - s.FromRemoteStorageConfig(v1API.StorageConfigResponseOutput{ - MigrationVersion: nullable.NewNullableWithValue("remote-metadata"), - }) - assert.Equal(t, "remote-metadata", s.TargetMigration) - }) - - t.Run("keeps the local migration version when the platform reports null", func(t *testing.T) { - s := storage{TargetMigration: "local-metadata"} - s.FromRemoteStorageConfig(v1API.StorageConfigResponseOutput{ - MigrationVersion: nullable.NewNullNullable[string](), - }) - assert.Equal(t, "local-metadata", s.TargetMigration) - }) - - t.Run("keeps the local migration version when the platform omits the field", func(t *testing.T) { - s := storage{TargetMigration: "local-metadata"} - s.FromRemoteStorageConfig(v1API.StorageConfigResponseOutput{}) - assert.Equal(t, "local-metadata", s.TargetMigration) - }) -} diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile deleted file mode 100644 index 74b0373f1d..0000000000 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# The tag pinned below is generated from packages/stack/src/Artifacts.ts by -# apps/cli/scripts/render-service-dockerfile.ts, for these aliases only: pg, pg15, mailpit, -# postgrest, pgmeta, studio, imgproxy, edgeruntime, vector, supavisor, gotrue, realtime, storage, -# logflare. Do not hand-edit those tags; a CI drift check enforces this. Run the generator after -# a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job -# images) is hand- or Dependabot-managed. Keep a line between image lines: git conflicts on edits to -# adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. -FROM supabase/postgres:17.11.0.006 AS pg - -FROM supabase/postgres:15.19.0.007 AS pg15 -# Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). -FROM supabase/postgres:14.1.0.89 AS pg14 -# New always-on service alias: extend SERVICE_IMAGE_ALIASES in -# shared/services/services.shared.ts and DOCKERFILE_ALIAS_BY_SERVICE in -# commands/start/start.gates.ts. -FROM library/kong:2.8.1 AS kong - -FROM axllent/mailpit:v1.31.3 AS mailpit - -FROM postgrest/postgrest:v16.5 AS postgrest - -FROM supabase/postgres-meta:v0.100.0 AS pgmeta - -FROM supabase/studio:2026.10.19-sha-b375769 AS studio - -FROM darthsim/imgproxy:v3.26.0 AS imgproxy - -FROM supabase/edge-runtime:v1.77.8 AS edgeruntime - -FROM timberio/vector:0.58.0-alpine AS vector - -FROM supabase/supavisor:2.9.13 AS supavisor - -FROM supabase/gotrue:v2.197.0 AS gotrue - -FROM supabase/realtime:v2.147.1 AS realtime - -FROM supabase/storage-api:v1.84.19 AS storage - -FROM supabase/logflare:1.50.15 AS logflare -# One-shot job images (not started as long-running containers); differ's alias -# is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. -FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ - -FROM supabase/migra:3.0.1663481299 AS migra - -FROM supabase/pg_prove:3.36 AS pgprove diff --git a/apps/cli-go/pkg/config/templates/certs/kong.local.crt b/apps/cli-go/pkg/config/templates/certs/kong.local.crt deleted file mode 100644 index 0a7b2dc53c..0000000000 --- a/apps/cli-go/pkg/config/templates/certs/kong.local.crt +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFJTCCAw2gAwIBAgIURUQAFIOhttOg0D12xZvx+pScX4cwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDcwNTE0MjI1NFoXDTM0MDcw -MzE0MjI1NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF -AAOCAg8AMIICCgKCAgEAyL79RrCl78/XutDm7tVNyIJNZqbNSfX+3AsnP1dQc37Q -7QkxmNJ2EmfklTLyvY1A+CY/o+3S1blK+O8lm40yTHP+GC30InKS7lSnCUNqSrHy -DaGNShtphqJoao9vo69/anI6I2tKc8VQ2tebvMVzmjrsvGtWcU4ciyzbUterKQas -Iin8Aw5gDCLswVNZnl6wUvlcgbLmiTLPaJKnNsbYcBJxST2SFxY5q5YWzlugm9Q0 -3913XB64rVoKAyQ86Yabl4UXyVXknyJcbpfnsmn6ORxnZJebnuU0O9y8ys/E9hGw -5rkVTm7QjzsbczMYOgvpPbR1miyDQaZ1bGyj9SmJo1H5e12xO0RNi+awuMnpt3cB -8NeTsZctOgnEQB1Gidq2XcYV48e2OGJTNtVVVPu1UKCNeJur1AfLNPRz8fJq06A+ -Xy5xeRNGFsaeYRt/IO+/CZUOHcY/vR55i8PeeTj9cB1f9KjfmQVeOt25widxrhFK -RkpRgA1ofQ4O8/gPSOcnroQgSs2Whr9RXIF71uSLJyZU0IBxru0cznuEpXxkh7oK -Fb6t/n1SZB2WEQXHh5iG49fw7MT29CzNpJIP1ApT3LKasHZHf4yBn9ZwVAagCGSL -W7kMDhIUhOfVwpyfkXYHzQE2aaCdQ9IfZy9ybO/Y8sbUg6B5i5t8xODutv+fmd0C -AwEAAaNvMG0wHQYDVR0OBBYEFGtw+BogdJ6GJRCe+BHGQdA0f85XMB8GA1UdIwQY -MBaAFGtw+BogdJ6GJRCe+BHGQdA0f85XMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0R -BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4ICAQCO9ITlkXil -+dLTddpSq06UuRTYLx8vWGLnd8uEoUg4qlLKi/hKnqTEP9dePw+QeuIM+aLjvvXY -bP4SqBOUi192OohQs7ZPCpjXizmabDNl4/Z+qyiGvZK/N0gWPEaTn6tzeQ03hECK -vY8lRrAP7xLrEYmA6unjfaj3inNUJeqL26i3SIAq/koK4jPm1F/AzwslNH00MJld -xKormGzvNJtaZPr5xrtuLQAmeCotE6av+R5k9zmSYa25Nel50JTk31fNiuUFmECD -qop+Yqfzw9/UkObDWw+g1+N3uHlH9CB2RvfkB2fW9SFMsznjQlq+MyQCwKFs699Q -rNMeHbUEwovDEcPUaLc3d5gCk/dvH9aR8mVLEcs7lMyHSsiG1Co1QXg6UjpMCrIV -Tvk+MrSQVVxw0rKS4H7ZEhfC2PfEVe1az24HrrPWJDZKnRhv1Ohj4jsxkBSprXsu -3qxcMCr7yr4hjsu9BsKtmvRfHTrMOwY1WtZBcl7hjgAItfMs29Fd4Tkn0aZs5f+n -GSwG5BeJL3kfEKwOjw8j+EAqRBu/7mbcoUyErnuLK7FimU4jtL8VX03n6nTgLJzS -xjUTaplgerrazAvV31vMaiINoaGM0RDnGjSCgZT4Va33noqo3qVJJOnWpheFmaXq -bUMQtwrAWTt12NECJt1nWAT4VWhfDoBE7A== ------END CERTIFICATE----- diff --git a/apps/cli-go/pkg/config/templates/certs/kong.local.key b/apps/cli-go/pkg/config/templates/certs/kong.local.key deleted file mode 100644 index 9b71aab612..0000000000 --- a/apps/cli-go/pkg/config/templates/certs/kong.local.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDIvv1GsKXvz9e6 -0Obu1U3Igk1mps1J9f7cCyc/V1BzftDtCTGY0nYSZ+SVMvK9jUD4Jj+j7dLVuUr4 -7yWbjTJMc/4YLfQicpLuVKcJQ2pKsfINoY1KG2mGomhqj2+jr39qcjoja0pzxVDa -15u8xXOaOuy8a1ZxThyLLNtS16spBqwiKfwDDmAMIuzBU1meXrBS+VyBsuaJMs9o -kqc2xthwEnFJPZIXFjmrlhbOW6Cb1DTf3XdcHritWgoDJDzphpuXhRfJVeSfIlxu -l+eyafo5HGdkl5ue5TQ73LzKz8T2EbDmuRVObtCPOxtzMxg6C+k9tHWaLINBpnVs -bKP1KYmjUfl7XbE7RE2L5rC4yem3dwHw15Oxly06CcRAHUaJ2rZdxhXjx7Y4YlM2 -1VVU+7VQoI14m6vUB8s09HPx8mrToD5fLnF5E0YWxp5hG38g778JlQ4dxj+9HnmL -w955OP1wHV/0qN+ZBV463bnCJ3GuEUpGSlGADWh9Dg7z+A9I5yeuhCBKzZaGv1Fc -gXvW5IsnJlTQgHGu7RzOe4SlfGSHugoVvq3+fVJkHZYRBceHmIbj1/DsxPb0LM2k -kg/UClPcspqwdkd/jIGf1nBUBqAIZItbuQwOEhSE59XCnJ+RdgfNATZpoJ1D0h9n -L3Js79jyxtSDoHmLm3zE4O62/5+Z3QIDAQABAoICAAnou5HYuGgtB0YWd7/EUbGb -FP0DMND8zpbICijrQ3JgCSoaMRONF/zymervd+H5bgKRyMRrweOA4P2GuIGEJ750 -X0+MPSfSJgSTsycW59FGFV+s4M+OqNfXfnX8HJOk3xI/DzWeXy91xvb2e56G1J0B -WZw3ZC31oB0SmsTtFmrRBIAvOFxiQkV3F963IY5auDNwUaupTA3rrydHTe+7QwXN -M0BdRK/VDgW1Q+ztwvDOOcnvIawvbAhKkIH1MPiKB3YkQLdUgROF27At2WcKKirV -hxkFDs56G+j0jXEpblyDJQtOiYm0mKDpVwKJvCFpYxsDpGZ22gfMkrFGb5VzdnK9 -6iMyz6eGCKBPd0I66nynZSfZwf+E3n0kGruBAEkTtGztyBxa1EUNul6uzv+Jw3No -o6cb5S7d/+4gFe6g1GoLMuWwtZyC7uXm0aZa64VgY713qkr1dDa5VHjLSs9+o2Jy -LeV6t/9/gcSn7uqtT6kQomYtpYVuEevKt/Bwj/r9Lc+KfPwOUsl5h0zUMw7c+1cL -NfCLadH1lpjB7AIiZwmKyGII4yiiIHSCZU8AKizJUvkW4TaPbtBhLkK6YYOJM+CY -DondkhlnyINaDKyH247Bfl0Ju2yl7IS3s3KUHZGbK1dBc5wC1jW7cp7yA9ZGxk8X -+5NIuozgp6Qd0swh/7XVAoIBAQD3wjJdMgqiuhveVY1tOpKa9mwdW/dSyY421Dvi -XUOEhgWPyheu78aahF3qrhY5yqX4oh643R4qQrok+7wphigLelMboqqb9qM/NL8R -m4jLvlHygHUQhrF8aGjRAKzfnOU0djpCLK1TJrvkxdZfo1ngzxfE9vo4OwtLcRuZ -GMWZNTthUyXEIkw6e4yoJvpk+cb7+TvTnVMjuks5tqI4OgXQ0fYmRixImRg/6PTC -QgsbG3FFxs71vrVJfcFmeymjq0gXHnviMDAgbqjnnHdqhsIauA0cByBfAQWrujUK -N9sTy/Fwni3j8Lxin5HxP8cZxkr2p7ynG98sfGMZFoqGn8JLAoIBAQDPbHRfJz69 -5Os82zumYXfNQw0pfiKn8Jve+dw1i/gSzj8wb98sRZ9w9scY5KBFH1tG5UcWdYtv -UJnUc1OO4mKmVpzrsQjS/qsGTPj2WkXEy3icoyJcDMFw6LBhctNHBwjtKL8Fe+ez -EPHR+Sy7WuvJphZhm817VQXFK5AAmfYJbraI7B9Qm7uP7o1+pv94+886BxWi6Kqm -wlEz+YhCQNPol5Lzth9/a3Ty0cwkaF5hG844Zu+feNWPdT7+nPs4QMWtCuQXD7cY -/UVjyUp8R5lL35woVqTyh2DUbawnAgvy5VR/q6nvnnorBSS5CCEokFTbcC2+5g2s -cquTixmnnzt3AoIBAEEAOApvWI782NbqByLDHTd+szq4qBiWPLNx1ww6f75DEy54 -TOJekfJ0C9fIOJiM7CJhFkJV8NYRj5ioWuNiCRuJykgT00L74DRy+DX8YlVh50oC -HbRqQiJrmiRLUw+PsCqlYf9+jTKllOIebXHF/4yViJzqVxdtljcCw8TU1PKvHpcY -I25juTAWvf5Xy5W2Wlg9OU75lZhoBdhsa/7oaiIxE61S98dMMyqBScW1YgiImJ0/ -3YhnslRHo/fpFDGWfGFkZAghsF6x6apqSIIwPLiZ1Qanb/y83eoB/mxh3x5AgWWU -emG1BRey2a07mFbQyVYRB9On4lNfhvhMC6fqPiUCggEBAIWznv+GyIaW/JrppfWF -dje1NNK+H5CpI5AC9tL9OhbFIy4RroVXC4NBTuzY0gY8RBXazIvJkOtbxQyQmhfM -DVKzGctC/jEjPxJ8oy4I2lgT39b3wLoc7sPc+XSXE2EBQ9u7HAZo690c2wNB3neW -D86XZG3ehAEvuNxzTAtXMqS53Kc9rKHFAI2yczpkYuCVT+pTjrrHxthfAQDDo/Uw -3Gdc7LhzeLIljejHGxOMwulNhwwMLgFZXi9uoAB3J+gGuGTmVEgZNSjCeOvtYDnj -3QhvRrUpxIEgimXjm8g5JYKYGHODL7LpME5yrk0m1FjVUB1yncojeETkVXxxeUP2 -+9kCggEBAMcsbVgvNVwt7bBf1zuw/AiXX4e3Lc2019M/z9SFsu6WfA+OgNMM90lQ -Eqq6XGhlNSDw+Zz8JDPq5RIM3rGlKFWAFSQ+h/4q67LgIDTOKjk7EXpbt/bJIFud -xyiG6uvMSX35OZ5vLsBvv2AzkzR5x2v9kcKD1b5eWn6haEXMOX/X0yGHux9vRylw -zEW1IOA5+2FZvZhwLy+F3XxY3Vrf04mWGyCxiBy4aMvbmp0BcWBg+tRLOmc6h7rX -2Bi8TfeE+GnmJkezD44MH/S+EEfafOkiDqoDCxxgYpJzXiE6DZNEUGdpUm87hdx4 -MviBG5FCtAAJp+5btxy72dIKBYBVUJ8= ------END PRIVATE KEY----- diff --git a/apps/cli-go/pkg/config/testdata/TestApiDiff/detects_differences.diff b/apps/cli-go/pkg/config/testdata/TestApiDiff/detects_differences.diff deleted file mode 100644 index 5d0e9603ef..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestApiDiff/detects_differences.diff +++ /dev/null @@ -1,14 +0,0 @@ -diff remote[api] local[api] ---- remote[api] -+++ local[api] -@@ -1,7 +1,7 @@ - enabled = true --schemas = ["public"] --extra_search_path = ["public"] --max_rows = 500 -+schemas = ["public", "private"] -+extra_search_path = ["extensions", "public"] -+max_rows = 1000 - port = 0 - external_url = "" - diff --git a/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_local_side.diff b/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_local_side.diff deleted file mode 100644 index 67a5ccd5ca..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_local_side.diff +++ /dev/null @@ -1,9 +0,0 @@ -diff remote[api] local[api] ---- remote[api] -+++ local[api] -@@ -1,4 +1,4 @@ --enabled = true -+enabled = false - schemas = ["public"] - extra_search_path = ["public"] - max_rows = 500 diff --git a/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_remote_side.diff b/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_remote_side.diff deleted file mode 100644 index 200d36c403..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestApiDiff/handles_api_disabled_on_remote_side.diff +++ /dev/null @@ -1,9 +0,0 @@ -diff remote[api] local[api] ---- remote[api] -+++ local[api] -@@ -1,4 +1,4 @@ --enabled = false -+enabled = true - schemas = ["public", "private"] - extra_search_path = ["extensions", "public"] - max_rows = 500 diff --git a/apps/cli-go/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff b/apps/cli-go/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff deleted file mode 100644 index 21094fdd40..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff +++ /dev/null @@ -1,30 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -1,16 +1,16 @@ - enabled = false --site_url = "" -+site_url = "http://127.0.0.1:3000" - external_url = "" --additional_redirect_urls = ["https://127.0.0.1:3000", "https://ref.supabase.co"] --jwt_expiry = 0 -+additional_redirect_urls = ["https://127.0.0.1:3000"] -+jwt_expiry = 3600 - jwt_issuer = "" --enable_refresh_token_rotation = true --refresh_token_reuse_interval = 0 --enable_manual_linking = true --enable_signup = true --enable_anonymous_sign_ins = true --minimum_password_length = 8 --password_requirements = "letters_digits" -+enable_refresh_token_rotation = false -+refresh_token_reuse_interval = 10 -+enable_manual_linking = false -+enable_signup = false -+enable_anonymous_sign_ins = false -+minimum_password_length = 6 -+password_requirements = "lower_upper_letters_digits_symbols" - signing_keys_path = "" - publishable_key = "" - secret_key = "" diff --git a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff deleted file mode 100644 index 2df3d18a24..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_disabled_remote_enabled.diff +++ /dev/null @@ -1,12 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -28,7 +28,7 @@ - web3 = 0 - - [captcha] --enabled = true -+enabled = false - provider = "turnstile" - secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e" - diff --git a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff deleted file mode 100644 index ea98e88092..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestCaptchaDiff/local_enabled_remote_disabled.diff +++ /dev/null @@ -1,16 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -28,9 +28,9 @@ - web3 = 0 - - [captcha] --enabled = false --provider = "hcaptcha" --secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" -+enabled = true -+provider = "turnstile" -+secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e" - - [hook] - diff --git a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/detects_differences.diff b/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/detects_differences.diff deleted file mode 100644 index 12aeb50ae3..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/detects_differences.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff remote[db.settings] local[db.settings] ---- remote[db.settings] -+++ local[db.settings] -@@ -1,3 +1,3 @@ --effective_cache_size = "8GB" --max_connections = 200 --shared_buffers = "2GB" -+effective_cache_size = "4GB" -+max_connections = 100 -+shared_buffers = "1GB" diff --git a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_local_side.diff b/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_local_side.diff deleted file mode 100644 index 392143fd13..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_local_side.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff remote[db.settings] local[db.settings] ---- remote[db.settings] -+++ local[db.settings] -@@ -1,3 +0,0 @@ --effective_cache_size = "4GB" --max_connections = 100 --shared_buffers = "1GB" diff --git a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_remote_side.diff b/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_remote_side.diff deleted file mode 100644 index 9382425387..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestDbSettingsDiff/handles_api_disabled_on_remote_side.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff remote[db.settings] local[db.settings] ---- remote[db.settings] -+++ local[db.settings] -@@ -0,0 +1,3 @@ -+effective_cache_size = "4GB" -+max_connections = 100 -+shared_buffers = "1GB" diff --git a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff deleted file mode 100644 index d341bab3f4..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_disabled_remote_enabled.diff +++ /dev/null @@ -1,57 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -49,13 +49,13 @@ - inactivity_timeout = "0s" - - [email] --enable_signup = true --double_confirm_changes = true --enable_confirmations = true --secure_password_change = true --max_frequency = "1s" --otp_length = 6 --otp_expiry = 3600 -+enable_signup = false -+double_confirm_changes = false -+enable_confirmations = false -+secure_password_change = false -+max_frequency = "1m0s" -+otp_length = 8 -+otp_expiry = 86400 - [email.template] - [email.template.confirmation] - content_path = "" -@@ -71,25 +71,25 @@ - content_path = "" - [email.notification] - [email.notification.email_changed] --enabled = true -+enabled = false - content_path = "" - [email.notification.identity_linked] --enabled = true -+enabled = false - content_path = "" - [email.notification.identity_unlinked] --enabled = true -+enabled = false - content_path = "" - [email.notification.mfa_factor_enrolled] --enabled = true -+enabled = false - content_path = "" - [email.notification.mfa_factor_unenrolled] --enabled = true -+enabled = false - content_path = "" - [email.notification.password_changed] --enabled = true -+enabled = false - content_path = "" - [email.notification.phone_changed] --enabled = true -+enabled = false - content_path = "" - - [sms] diff --git a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff deleted file mode 100644 index c3a49ddf02..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestEmailDiff/local_enabled_remote_disabled.diff +++ /dev/null @@ -1,99 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -49,62 +49,74 @@ - inactivity_timeout = "0s" - - [email] --enable_signup = false --double_confirm_changes = false --enable_confirmations = false --secure_password_change = false --max_frequency = "1m0s" --otp_length = 6 --otp_expiry = 3600 -+enable_signup = true -+double_confirm_changes = true -+enable_confirmations = true -+secure_password_change = true -+max_frequency = "1s" -+otp_length = 8 -+otp_expiry = 86400 - [email.template] - [email.template.confirmation] -+subject = "confirmation-subject" - content_path = "" - [email.template.email_change] -+subject = "email-change-subject" - content = "email-change-content" - content_path = "" - [email.template.invite] -+subject = "invite-subject" -+content = "invite-content" - content_path = "" - [email.template.magic_link] - subject = "magic-link-subject" -+content = "magic-link-content" - content_path = "" - [email.template.reauthentication] -+subject = "" -+content = "" - content_path = "" - [email.template.recovery] -+content = "recovery-content" - content_path = "" - [email.notification] - [email.notification.email_changed] --enabled = false -+enabled = true - subject = "email-changed-subject" -+content = "email-changed-content" - content_path = "" - [email.notification.identity_linked] --enabled = false -+enabled = true -+subject = "identity-linked-subject" -+content = "identity-linked-content" - content_path = "" - [email.notification.identity_unlinked] --enabled = false -+enabled = true - content_path = "" - [email.notification.mfa_factor_enrolled] --enabled = false -+enabled = true - content_path = "" - [email.notification.mfa_factor_unenrolled] --enabled = false -+enabled = true - content_path = "" - [email.notification.password_changed] --enabled = false -+enabled = true - subject = "password-changed-subject" - content = "password-changed-content" - content_path = "" - [email.notification.phone_changed] --enabled = false -+enabled = true -+subject = "phone-changed-subject" - content = "phone-changed-content" - content_path = "" - [email.smtp] --enabled = false --host = "" --port = 0 --user = "" --pass = "" --admin_email = "" --sender_name = "" -+enabled = true -+host = "smtp.sendgrid.net" -+port = 587 -+user = "apikey" -+pass = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e" -+admin_email = "admin@email.com" -+sender_name = "Admin" - - [sms] - enable_signup = false diff --git a/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff b/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff deleted file mode 100644 index 54ca03e506..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestExternalDiff/local_enabled_and_disabled.diff +++ /dev/null @@ -1,34 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -89,7 +89,7 @@ - - [external] - [external.apple] --enabled = false -+enabled = true - client_id = "test-client-1,test-client-2" - secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" - url = "" -@@ -97,9 +97,9 @@ - skip_nonce_check = false - email_optional = false - [external.azure] --enabled = false --client_id = "" --secret = "" -+enabled = true -+client_id = "test-client-1" -+secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" - url = "" - redirect_uri = "" - skip_nonce_check = false -@@ -153,7 +153,7 @@ - skip_nonce_check = false - email_optional = false - [external.google] --enabled = true -+enabled = false - client_id = "test-client-2" - secret = "" - url = "" diff --git a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff deleted file mode 100644 index d0b4dcc30e..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_disabled_remote_enabled.diff +++ /dev/null @@ -1,32 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -29,23 +29,23 @@ - - [hook] - [hook.mfa_verification_attempt] --enabled = true -+enabled = false - uri = "pg-functions://postgres/public/verifyMFA" - secrets = "" - [hook.custom_access_token] --enabled = true -+enabled = false - uri = "" - secrets = "" - [hook.send_sms] --enabled = true -+enabled = false - uri = "https://example.com" - secrets = "" - [hook.send_email] --enabled = true -+enabled = false - uri = "" - secrets = "" - [hook.before_user_created] --enabled = true -+enabled = false - uri = "" - secrets = "" - diff --git a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff deleted file mode 100644 index 3c3364d17b..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestHookDiff/local_enabled_remote_disabled.diff +++ /dev/null @@ -1,39 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -29,25 +29,25 @@ - - [hook] - [hook.mfa_verification_attempt] --enabled = false -+enabled = true - uri = "pg-functions://postgres/public/verifyMFA" - secrets = "" - [hook.custom_access_token] --enabled = false --uri = "pg-functions://postgres/public/customToken" --secrets = "" -+enabled = true -+uri = "http://example.com" -+secrets = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" - [hook.send_sms] --enabled = false -+enabled = true - uri = "https://example.com" - secrets = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" - [hook.send_email] --enabled = false --uri = "https://example.com" -+enabled = true -+uri = "pg-functions://postgres/public/sendEmail" - secrets = "" - [hook.before_user_created] --enabled = false --uri = "pg-functions://postgres/public/beforeUserCreated" --secrets = "" -+enabled = true -+uri = "http://example.com" -+secrets = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252" - - [mfa] - max_enrolled_factors = 0 diff --git a/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff b/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff deleted file mode 100644 index ec1f6ebe90..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestMfaDiff/local_enabled_and_disabled.diff +++ /dev/null @@ -1,26 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -30,16 +30,16 @@ - [hook] - - [mfa] --max_enrolled_factors = 10 -+max_enrolled_factors = 0 - [mfa.totp] - enroll_enabled = false - verify_enabled = false - [mfa.phone] --enroll_enabled = false --verify_enabled = false --otp_length = 6 --template = "Your code is {{ .Code }}" --max_frequency = "5s" -+enroll_enabled = true -+verify_enabled = true -+otp_length = 0 -+template = "" -+max_frequency = "0s" - [mfa.web_authn] - enroll_enabled = false - verify_enabled = false diff --git a/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff b/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff deleted file mode 100644 index 9663c1438b..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestRateLimitsDiff/local_and_remote_rate_limits_differ.diff +++ /dev/null @@ -1,20 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -19,12 +19,12 @@ - service_role_key = "" - - [rate_limit] --anonymous_users = 10 -+anonymous_users = 20 - token_refresh = 30 --sign_in_sign_ups = 45 -+sign_in_sign_ups = 40 - token_verifications = 50 --email_sent = 15 --sms_sent = 55 -+email_sent = 25 -+sms_sent = 35 - web3 = 0 - - [hook] diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff deleted file mode 100644 index 2e44496fd3..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/enable_sign_up_without_provider.diff +++ /dev/null @@ -1,12 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -58,7 +58,7 @@ - otp_expiry = 0 - - [sms] --enable_signup = false -+enable_signup = true - enable_confirmations = false - template = "" - max_frequency = "0s" diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff deleted file mode 100644 index 385f49fb83..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_disabled_remote_enabled.diff +++ /dev/null @@ -1,30 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -58,12 +58,12 @@ - otp_expiry = 0 - - [sms] --enable_signup = true --enable_confirmations = true --template = "Your code is {{ .Code }}" --max_frequency = "1m0s" -+enable_signup = false -+enable_confirmations = false -+template = "" -+max_frequency = "0s" - [sms.twilio] --enabled = true -+enabled = false - account_sid = "" - message_service_sid = "" - auth_token = "" -@@ -86,8 +86,6 @@ - api_key = "" - api_secret = "" - [sms.test_otp] --123 = "456" --456 = "123" - - [web3] - [web3.solana] diff --git a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff b/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff deleted file mode 100644 index 3db44eabab..0000000000 --- a/apps/cli-go/pkg/config/testdata/TestSmsDiff/local_enabled_remote_disabled.diff +++ /dev/null @@ -1,42 +0,0 @@ -diff remote[auth] local[auth] ---- remote[auth] -+++ local[auth] -@@ -58,12 +58,12 @@ - otp_expiry = 0 - - [sms] --enable_signup = false --enable_confirmations = false --template = "" --max_frequency = "0s" -+enable_signup = true -+enable_confirmations = true -+template = "Your code is {{ .Code }}" -+max_frequency = "1m0s" - [sms.twilio] --enabled = true -+enabled = false - account_sid = "" - message_service_sid = "" - auth_token = "" -@@ -73,9 +73,9 @@ - message_service_sid = "" - auth_token = "" - [sms.messagebird] --enabled = false --originator = "" --access_key = "" -+enabled = true -+originator = "test-originator" -+access_key = "hash:ab60d03fc809fb02dae838582f3ddc13d1d6cb32ffba77c4b969dd3caa496f13" - [sms.textlocal] - enabled = false - sender = "" -@@ -86,6 +86,7 @@ - api_key = "" - api_secret = "" - [sms.test_otp] -+123 = "456" - - [web3] - [web3.solana] diff --git a/apps/cli-go/pkg/config/testdata/config.toml b/apps/cli-go/pkg/config/testdata/config.toml deleted file mode 100644 index 4c6ffc0c80..0000000000 --- a/apps/cli-go/pkg/config/testdata/config.toml +++ /dev/null @@ -1,378 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "test" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. public is always included. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = true -# Paths to self-signed certificate pair. -cert_path = "../certs/my-cert.pem" -key_path = "../certs/my-key.pem" - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# Maximum amount of time to wait for health check when starting the local database. -health_timeout = "2m" -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 -orioledb_version = "15.1.0.150" - -[db.migrations] -# If disabled, migrations will be skipped during a db push or reset. -enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = ["./schemas/*.sql"] - -[db.pooler] -enabled = true -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -[db.vault] -test_key = "test_value" - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[db.network_restrictions] -# Enable management of network restrictions. -enabled = true -# List of IPv4 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv4 connections. Set empty array to block all IPs. -allowed_cidrs = ["0.0.0.0/0"] -# List of IPv6 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv6 connections. Set empty array to block all IPs. -allowed_cidrs_v6 = ["::/0"] - -# Uncomment to reject non-secure connections to the database. -[db.ssl_enforcement] -enabled = true - -[realtime] -enabled = true -# Bind realtime via either IPv4 or IPv6. (default: IPv6) -ip_version = "IPv4" -# The maximum length in bytes of HTTP request headers. (default: 4096) -max_header_length = 8192 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[local_smtp] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Uncomment to configure local storage buckets -[storage.buckets.images] -public = false -file_size_limit = "50MiB" -allowed_mime_types = ["image/png", "image/jpeg"] -objects_path = "./images" - -# Allow connections via S3 compatible clients -[storage.s3_protocol] -enabled = true - -# Image transformation API is available to Supabase Pro plan. -[storage.image_transformation] -enabled = true - -# Store analytical data in S3 for running ETL jobs over Iceberg Catalog -[storage.analytics] -enabled = true -max_namespaces = 5 -max_tables = 10 -max_catalogs = 2 - -# Analytics Buckets is available to Supabase Pro plan. -[storage.analytics.buckets.my-warehouse] - -# Store vector embeddings in S3 for large and durable datasets -[storage.vector] -enabled = true -max_buckets = 10 -max_indexes = 5 - -# Vector Buckets is available to Supabase Pro plan. -# [storage.vector.buckets.documents-openai] - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000", "env(AUTH_CALLBACK_URL)"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# Path to JWT signing key. DO NOT commit your signing keys file to git. -signing_keys_path = "./signing_keys.json" -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = true -# Allow/disallow testing manual linking of accounts -enable_manual_linking = true -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = true -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = true - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 -# Number of Web3 logins that can be made in a 5 minute interval per IP address. -web3 = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -[auth.captcha] -enabled = true -provider = "hcaptcha" -secret = "env(HCAPTCHA_SECRET)" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = true -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -[auth.email.smtp] -enabled = true -host = "smtp.sendgrid.net" -port = 587 -user = "apikey" -pass = "env(SENDGRID_API_KEY)" -admin_email = "admin@email.com" -sender_name = "Admin" - -# Uncomment to customize email template -[auth.email.template.invite] -subject = "You have been invited" -content_path = "./supabase/templates/invite.html" - -# Uncomment to customize notification email template -[auth.email.notification.password_changed] -enabled = true -subject = "Your password has been changed" -content_path = "./templates/password_changed_notification.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = true -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ `{{ .Code }}` }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -[auth.sms.test_otp] -4152127777 = "123456" - -# Configure logged in session timeouts. -[auth.sessions] -# Force log out after the specified duration. -timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -inactivity_timeout = "8h" - -# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -[auth.hook.before_user_created] -enabled = true -uri = "pg-functions://postgres/auth/before-user-created-hook" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -[auth.hook.custom_access_token] -enabled = true -uri = "pg-functions://postgres/auth/custom-access-token-hook" - -[auth.hook.send_sms] -enabled = true -uri = "http://host.docker.internal/functions/v1/send_sms" -secrets = "env(AUTH_SEND_SMS_SECRETS)" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = true -account_sid = "account_sid" -message_service_sid = "message_service_sid" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -max_enrolled_factors = 10 - -# Configure MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = true -verify_enabled = true - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = true -verify_enabled = true -otp_length = 6 -template = "Your code is {{ `{{ .Code }}` }}" -max_frequency = "5s" - -# Configure MFA via Phone Messaging -[auth.mfa.web_authn] -enroll_enabled = true -verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.azure] -enabled = true -client_id = "env(AZURE_CLIENT_ID)" -secret = "env(AZURE_SECRET)" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "https://login.microsoftonline.com/tenant" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = true -# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. -email_optional = true - -# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. -# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. -[auth.web3.solana] -enabled = true - -[edge_runtime] -enabled = true -# Configure one of the supported request policies: `oneshot`, `per_worker`. -# Use `oneshot` for hot reload, or `per_worker` for load testing. -policy = "per_worker" -inspector_port = 8083 -deno_version = 2 - -[edge_runtime.secrets] -test_key = "test_value" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "orioledb.s3-accelerate.amazonaws.com" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "ap-southeast-1" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "" - -[remotes.production] -project_id = "vpefcjyosynxeiebfscx" - -[remotes.production.auth] -site_url = "http://feature-auth-branch.com/" -enable_signup = false - -[remotes.production.auth.external.azure] -enabled = false -client_id = "nope" - -[remotes.staging] -project_id = "bvikqvbczudanvggcord" - -[remotes.staging.db.seed] -enabled = true - -[remotes.staging.storage.buckets.images] -allowed_mime_types = ["image/png"] diff --git a/apps/cli-go/pkg/config/updater.go b/apps/cli-go/pkg/config/updater.go deleted file mode 100644 index 6c7e7eecd1..0000000000 --- a/apps/cli-go/pkg/config/updater.go +++ /dev/null @@ -1,318 +0,0 @@ -package config - -import ( - "context" - "fmt" - "os" - - "github.com/go-errors/errors" - v1API "github.com/supabase/cli/pkg/api" -) - -type ConfigUpdater struct { - client v1API.ClientWithResponses -} - -func NewConfigUpdater(client v1API.ClientWithResponses) ConfigUpdater { - return ConfigUpdater{client: client} -} - -func (u *ConfigUpdater) UpdateRemoteConfig(ctx context.Context, remote baseConfig, filter ...func(string) bool) error { - if err := u.UpdateApiConfig(ctx, remote.ProjectId, remote.Api, filter...); err != nil { - return err - } - if err := u.UpdateDbConfig(ctx, remote.ProjectId, remote.Db, filter...); err != nil { - return err - } - if err := u.UpdateAuthConfig(ctx, remote.ProjectId, remote.Auth, filter...); err != nil { - return err - } - if err := u.UpdateStorageConfig(ctx, remote.ProjectId, remote.Storage, filter...); err != nil { - return err - } - if err := u.UpdateExperimentalConfig(ctx, remote.ProjectId, remote.Experimental, filter...); err != nil { - return err - } - return nil -} - -func (u *ConfigUpdater) UpdateApiConfig(ctx context.Context, projectRef string, c api, filter ...func(string) bool) error { - apiConfig, err := u.client.V1GetPostgrestServiceConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read API config: %w", err) - } else if apiConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", apiConfig.StatusCode(), string(apiConfig.Body)) - } - apiDiff, err := c.DiffWithRemote(*apiConfig.JSON200) - if err != nil { - return err - } else if len(apiDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote API config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating API service with config:", string(apiDiff)) - for _, keep := range filter { - if !keep("api") { - return nil - } - } - if resp, err := u.client.V1UpdatePostgrestServiceConfigWithResponse(ctx, projectRef, c.ToUpdatePostgrestConfigBody()); err != nil { - return errors.Errorf("failed to update API config: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateDbSettingsConfig(ctx context.Context, projectRef string, s settings, filter ...func(string) bool) error { - dbConfig, err := u.client.V1GetPostgresConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read DB config: %w", err) - } else if dbConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", dbConfig.StatusCode(), string(dbConfig.Body)) - } - dbDiff, err := s.DiffWithRemote(*dbConfig.JSON200) - if err != nil { - return err - } else if len(dbDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote DB config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating DB service with config:", string(dbDiff)) - for _, keep := range filter { - if !keep("db") { - return nil - } - } - updateBody := s.ToUpdatePostgresConfigBody() - if resp, err := u.client.V1UpdatePostgresConfigWithResponse(ctx, projectRef, updateBody); err != nil { - return errors.Errorf("failed to update DB config: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateDbConfig(ctx context.Context, projectRef string, c db, filter ...func(string) bool) error { - if err := u.UpdateDbSettingsConfig(ctx, projectRef, c.Settings, filter...); err != nil { - return err - } - if err := u.UpdateDbNetworkRestrictionsConfig(ctx, projectRef, c.NetworkRestrictions, filter...); err != nil { - return err - } - if c.SslEnforcement != nil { - return u.UpdateSslEnforcement(ctx, projectRef, *c.SslEnforcement, filter...) - } - return nil -} - -func (u *ConfigUpdater) UpdateDbNetworkRestrictionsConfig(ctx context.Context, projectRef string, n networkRestrictions, filter ...func(string) bool) error { - if !n.Enabled { - return nil - } - networkRestrictionsConfig, err := u.client.V1GetNetworkRestrictionsWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read network restrictions config: %w", err) - } else if networkRestrictionsConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", networkRestrictionsConfig.StatusCode(), string(networkRestrictionsConfig.Body)) - } - networkRestrictionsDiff, err := n.DiffWithRemote(*networkRestrictionsConfig.JSON200) - if err != nil { - return err - } else if len(networkRestrictionsDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote DB Network restrictions config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating network restrictions with config:", string(networkRestrictionsDiff)) - for _, keep := range filter { - if !keep("db") { - return nil - } - } - updateBody := n.ToUpdateNetworkRestrictionsBody() - if resp, err := u.client.V1UpdateNetworkRestrictionsWithResponse(ctx, projectRef, updateBody); err != nil { - return errors.Errorf("failed to update network restrictions config: %w", err) - } else if resp.JSON201 == nil { - return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateSslEnforcement(ctx context.Context, projectRef string, s sslEnforcement, filter ...func(string) bool) error { - sslEnforcementConfig, err := u.client.V1GetSslEnforcementConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read SSL enforcement config: %w", err) - } else if sslEnforcementConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", sslEnforcementConfig.StatusCode(), string(sslEnforcementConfig.Body)) - } - sslEnforcementDiff, err := s.DiffWithRemote(*sslEnforcementConfig.JSON200) - if err != nil { - return err - } else if len(sslEnforcementDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote DB SSL enforcement config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating SSL enforcement with config:", string(sslEnforcementDiff)) - for _, keep := range filter { - if !keep("db") { - return nil - } - } - updateBody := s.ToUpdateSslEnforcementBody() - if resp, err := u.client.V1UpdateSslEnforcementConfigWithResponse(ctx, projectRef, updateBody); err != nil { - return errors.Errorf("failed to update SSL enforcement config: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateAuthConfig(ctx context.Context, projectRef string, c auth, filter ...func(string) bool) error { - if !c.Enabled { - return nil - } - authConfig, err := u.client.V1GetAuthServiceConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read Auth config: %w", err) - } else if authConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", authConfig.StatusCode(), string(authConfig.Body)) - } - authDiff, err := c.DiffWithRemote(*authConfig.JSON200, filter...) - if err != nil { - return err - } else if len(authDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote Auth config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating Auth service with config:", string(authDiff)) - for _, keep := range filter { - if !keep("auth") { - return nil - } - } - if resp, err := u.client.V1UpdateAuthServiceConfigWithResponse(ctx, projectRef, c.ToUpdateAuthConfigBody()); err != nil { - return errors.Errorf("failed to update Auth config: %w", err) - } else if status := resp.StatusCode(); status < 200 || status >= 300 { - return errors.Errorf("unexpected status %d: %s", status, string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateSigningKeys(ctx context.Context, projectRef string, signingKeys []JWK, filter ...func(string) bool) error { - if len(signingKeys) == 0 { - return nil - } - resp, err := u.client.V1GetProjectSigningKeysWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to fetch signing keys: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) - } - exists := map[string]struct{}{} - for _, k := range resp.JSON200.Keys { - if k.PublicJwk != nil { - exists[k.Id.String()] = struct{}{} - } - } - var toInsert []JWK - for _, k := range signingKeys { - if _, ok := exists[k.KeyID]; !ok { - toInsert = append(toInsert, k) - } - } - if len(toInsert) == 0 { - fmt.Fprintln(os.Stderr, "Remote JWT signing keys are up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "JWT signing keys to insert:") - for _, k := range toInsert { - fmt.Fprintln(os.Stderr, " -", k.KeyID) - } - for _, keep := range filter { - if !keep("signing keys") { - return nil - } - } - for _, k := range toInsert { - body := v1API.CreateSigningKeyBody{ - Algorithm: v1API.CreateSigningKeyBodyAlgorithm(k.Algorithm), - PrivateJwk: &v1API.CreateSigningKeyBody_PrivateJwk{}, - } - switch k.Algorithm { - case AlgRS256: - body.PrivateJwk.FromCreateSigningKeyBodyPrivateJwk0(v1API.CreateSigningKeyBodyPrivateJwk0{ - D: k.PrivateExponent, - Dp: k.FirstFactorCRTExponent, - Dq: k.SecondFactorCRTExponent, - E: v1API.CreateSigningKeyBodyPrivateJwk0E(k.Exponent), - Kty: v1API.CreateSigningKeyBodyPrivateJwk0Kty(k.KeyType), - N: k.Modulus, - P: k.FirstPrimeFactor, - Q: k.SecondPrimeFactor, - Qi: k.FirstCRTCoefficient, - }) - case AlgES256: - body.PrivateJwk.FromCreateSigningKeyBodyPrivateJwk1(v1API.CreateSigningKeyBodyPrivateJwk1{ - Crv: v1API.CreateSigningKeyBodyPrivateJwk1Crv(k.Curve), - D: k.PrivateExponent, - Kty: v1API.CreateSigningKeyBodyPrivateJwk1Kty(k.KeyType), - X: k.X, - Y: k.Y, - }) - } - if resp, err := u.client.V1CreateProjectSigningKeyWithResponse(ctx, projectRef, body); err != nil { - return errors.Errorf("failed to add signing key: %w", err) - } else if status := resp.StatusCode(); status < 200 || status >= 300 { - return errors.Errorf("unexpected status %d: %s", status, string(resp.Body)) - } - } - return nil -} - -func (u *ConfigUpdater) UpdateStorageConfig(ctx context.Context, projectRef string, c storage, filter ...func(string) bool) error { - if !c.Enabled { - return nil - } - storageConfig, err := u.client.V1GetStorageConfigWithResponse(ctx, projectRef) - if err != nil { - return errors.Errorf("failed to read Storage config: %w", err) - } else if storageConfig.JSON200 == nil { - return errors.Errorf("unexpected status %d: %s", storageConfig.StatusCode(), string(storageConfig.Body)) - } - storageDiff, err := c.DiffWithRemote(*storageConfig.JSON200) - if err != nil { - return err - } else if len(storageDiff) == 0 { - fmt.Fprintln(os.Stderr, "Remote Storage config is up to date.") - return nil - } - fmt.Fprintln(os.Stderr, "Updating Storage service with config:", string(storageDiff)) - for _, keep := range filter { - if !keep("storage") { - return nil - } - } - if resp, err := u.client.V1UpdateStorageConfigWithResponse(ctx, projectRef, c.ToUpdateStorageConfigBody()); err != nil { - return errors.Errorf("failed to update Storage config: %w", err) - } else if status := resp.StatusCode(); status < 200 || status >= 300 { - return errors.Errorf("unexpected status %d: %s", status, string(resp.Body)) - } - return nil -} - -func (u *ConfigUpdater) UpdateExperimentalConfig(ctx context.Context, projectRef string, exp experimental, filter ...func(string) bool) error { - if exp.Webhooks != nil && exp.Webhooks.Enabled { - fmt.Fprintln(os.Stderr, "Enabling webhooks for project:", projectRef) - for _, keep := range filter { - if !keep("webhooks") { - return nil - } - } - if resp, err := u.client.V1EnableDatabaseWebhookWithResponse(ctx, projectRef); err != nil { - return errors.Errorf("failed to enable webhooks: %w", err) - } else if status := resp.StatusCode(); status < 200 || status >= 300 { - return errors.Errorf("unexpected enable webhook status %d: %s", status, string(resp.Body)) - } - } - return nil -} diff --git a/apps/cli-go/pkg/config/updater_test.go b/apps/cli-go/pkg/config/updater_test.go deleted file mode 100644 index 0f00aba3b8..0000000000 --- a/apps/cli-go/pkg/config/updater_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package config - -import ( - "context" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/oapi-codegen/nullable" - openapi_types "github.com/oapi-codegen/runtime/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - v1API "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" -) - -func TestUpdateApi(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("updates remote config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/postgrest"). - Reply(http.StatusOK). - JSON(v1API.PostgrestConfigWithJWTSecretResponseOutput{}) - gock.New(server). - Patch("/v1/projects/test-project/postgrest"). - Reply(http.StatusOK). - JSON(v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public,graphql_public", - DbExtraSearchPath: "public,extensions", - MaxRows: 1000, - }) - // Run test - err := updater.UpdateApiConfig(context.Background(), "test-project", api{ - Enabled: true, - Schemas: []string{"public", "graphql_public"}, - ExtraSearchPath: []string{"public", "extensions"}, - MaxRows: 1000, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if no diff", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/postgrest"). - Reply(http.StatusOK). - JSON(v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "", - DbExtraSearchPath: "public,extensions", - MaxRows: 1000, - }) - // Run test - err := updater.UpdateApiConfig(context.Background(), "test-project", api{}) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) -} - -func TestUpdateDbConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("updates remote DB config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/config/database"). - Reply(http.StatusOK). - JSON(v1API.PostgresConfigResponseOutput{}) - gock.New(server). - Put("/v1/projects/test-project/config/database"). - Reply(http.StatusOK). - JSON(v1API.PostgresConfigResponseOutput{ - MaxConnections: cast.Ptr(cast.UintToInt(100)), - }) - // Run test - err := updater.UpdateDbConfig(context.Background(), "test-project", db{ - Settings: settings{ - MaxConnections: cast.Ptr(cast.IntToUint(100)), - }, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if no diff in DB config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/config/database"). - Reply(http.StatusOK). - JSON(v1API.PostgresConfigResponseOutput{ - MaxConnections: cast.Ptr(cast.UintToInt(100)), - }) - // Run test - err := updater.UpdateDbConfig(context.Background(), "test-project", db{ - Settings: settings{ - MaxConnections: cast.Ptr(cast.IntToUint(100)), - }, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) -} - -func TestUpdateDbNetworkRestrictionsConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("skips update if disabled locally", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Run test - err := updater.UpdateDbNetworkRestrictionsConfig(context.Background(), "test-project", networkRestrictions{}) - // Check result - assert.NoError(t, err) - assert.False(t, gock.HasUnmatchedRequest()) - }) - - t.Run("returns error on 400 when enabled locally", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/network-restrictions"). - Reply(http.StatusBadRequest). - JSON(map[string]any{ - "message": "project not allowed to set up network restrictions", - }) - // Run test - err := updater.UpdateDbNetworkRestrictionsConfig(context.Background(), "test-project", networkRestrictions{ - Enabled: true, - }) - // Check result - assert.Error(t, err) - assert.Contains(t, err.Error(), "unexpected status 400") - assert.True(t, gock.IsDone()) - }) -} - -func TestUpdateExperimentalConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("enables webhooks", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Post("/v1/projects/test-project/database/webhooks/enable"). - Reply(http.StatusOK). - JSON(map[string]any{}) - // Run test - err := updater.UpdateExperimentalConfig(context.Background(), "test-project", experimental{ - Webhooks: &webhooks{ - Enabled: true, - }, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if webhooks not enabled", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Run test - err := updater.UpdateExperimentalConfig(context.Background(), "test-project", experimental{ - Webhooks: &webhooks{ - Enabled: false, - }, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) -} - -func TestUpdateAuthConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("updates remote Auth config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/config/auth"). - Reply(http.StatusOK). - JSON(v1API.AuthConfigResponseOutput{ - SiteUrl: nullable.NewNullableWithValue("http://localhost:3000"), - SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("abc@example.com")), - }) - gock.New(server). - Patch("/v1/projects/test-project/config/auth"). - Reply(http.StatusOK) - // Run test - err := updater.UpdateAuthConfig(context.Background(), "test-project", auth{Enabled: true}) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if no diff in Auth config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/config/auth"). - Reply(http.StatusOK). - JSON(v1API.AuthConfigResponseOutput{ - SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("abc@example.com")), - }) - // Run test - err := updater.UpdateAuthConfig(context.Background(), "test-project", auth{ - Enabled: true, - EnableSignup: true, - AdditionalRedirectUrls: []string{}, - Email: email{EnableConfirmations: true}, - Sms: sms{TestOTP: map[string]string{}}, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if disabled locally", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Run test - err := updater.UpdateAuthConfig(context.Background(), "test-project", auth{}) - // Check result - assert.NoError(t, err) - }) -} - -func TestUpdateStorageConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("updates remote Storage config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - mockStorage := v1API.StorageConfigResponseOutput{ - FileSizeLimit: 100, - } - mockStorage.Features.ImageTransformation.Enabled = true - mockStorage.Features.S3Protocol.Enabled = true - gock.New(server). - Get("/v1/projects/test-project/config/storage"). - Reply(http.StatusOK). - JSON(mockStorage) - gock.New(server). - Patch("/v1/projects/test-project/config/storage"). - Reply(http.StatusOK) - // Run test - err := updater.UpdateStorageConfig(context.Background(), "test-project", storage{Enabled: true}) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if no diff in Storage config", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - gock.New(server). - Get("/v1/projects/test-project/config/storage"). - Reply(http.StatusOK). - JSON(v1API.StorageConfigResponseOutput{}) - // Run test - err := updater.UpdateStorageConfig(context.Background(), "test-project", storage{Enabled: true}) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) - - t.Run("skips update if disabled locally", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Run test - err := updater.UpdateStorageConfig(context.Background(), "test-project", storage{}) - // Check result - assert.NoError(t, err) - }) -} - -func TestUpdateRemoteConfig(t *testing.T) { - server := "http://localhost" - client, err := v1API.NewClientWithResponses(server) - require.NoError(t, err) - - t.Run("updates all configs", func(t *testing.T) { - updater := NewConfigUpdater(*client) - // Setup mock server - defer gock.Off() - // API config - gock.New(server). - Get("/v1/projects/test-project/postgrest"). - Reply(http.StatusOK). - JSON(v1API.PostgrestConfigWithJWTSecretResponseOutput{}) - gock.New(server). - Patch("/v1/projects/test-project/postgrest"). - Reply(http.StatusOK). - JSON(v1API.PostgrestConfigWithJWTSecretResponseOutput{ - DbSchema: "public", - MaxRows: 1000, - }) - // DB config - gock.New(server). - Get("/v1/projects/test-project/config/database"). - Reply(http.StatusOK). - JSON(v1API.PostgresConfigResponseOutput{}) - gock.New(server). - Put("/v1/projects/test-project/config/database"). - Reply(http.StatusOK). - JSON(v1API.PostgresConfigResponseOutput{ - MaxConnections: cast.Ptr(cast.UintToInt(100)), - }) - // Auth config - gock.New(server). - Get("/v1/projects/test-project/config/auth"). - Reply(http.StatusOK). - JSON(v1API.AuthConfigResponseOutput{ - SmtpAdminEmail: nullable.NewNullableWithValue(openapi_types.Email("abc@example.com")), - }) - gock.New(server). - Patch("/v1/projects/test-project/config/auth"). - Reply(http.StatusOK) - // Storage config - gock.New(server). - Get("/v1/projects/test-project/config/storage"). - Reply(http.StatusOK). - JSON(v1API.StorageConfigResponseOutput{}) - gock.New(server). - Patch("/v1/projects/test-project/config/storage"). - Reply(http.StatusOK) - // Experimental config - gock.New(server). - Post("/v1/projects/test-project/database/webhooks/enable"). - Reply(http.StatusOK). - JSON(map[string]any{}) - // Run test - err := updater.UpdateRemoteConfig(context.Background(), baseConfig{ - ProjectId: "test-project", - Api: api{ - Enabled: true, - Schemas: []string{"public", "private"}, - MaxRows: 1000, - }, - Db: db{ - Settings: settings{ - MaxConnections: cast.Ptr(cast.IntToUint(100)), - }, - }, - Auth: auth{ - Enabled: true, - SiteUrl: "http://localhost:3000", - }, - Storage: storage{ - Enabled: true, - FileSizeLimit: 100, - ImageTransformation: &imageTransformation{ - Enabled: true, - }, - S3Protocol: &s3Protocol{ - Enabled: true, - }, - }, - Experimental: experimental{ - Webhooks: &webhooks{ - Enabled: true, - }, - }, - }) - // Check result - assert.NoError(t, err) - assert.True(t, gock.IsDone()) - }) -} diff --git a/apps/cli-go/pkg/config/utils.go b/apps/cli-go/pkg/config/utils.go deleted file mode 100644 index bd2b9512b6..0000000000 --- a/apps/cli-go/pkg/config/utils.go +++ /dev/null @@ -1,150 +0,0 @@ -package config - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "fmt" - "io/fs" - "os" - "path/filepath" - "strconv" - "strings" -) - -// StackStateEnv is set by the TypeScript CLI when the project keeps its linked state outside -// supabase/.temp, so the files under it are neither read nor written. -const StackStateEnv = "SUPABASE_INTERNAL_STACK_STATE" - -// StackPoolerUrlEnv carries the linked pooler connection string in stack mode, where -// supabase/.temp/pooler-url is not read. -const StackPoolerUrlEnv = "SUPABASE_INTERNAL_POOLER_URL" - -// UsesStackState reports whether supabase/.temp is off limits for this invocation. -func UsesStackState() bool { - enabled, _ := strconv.ParseBool(os.Getenv(StackStateEnv)) - return enabled -} - -// readTempFile reads a file under supabase/.temp, which reads as absent in stack mode. -func readTempFile(fsys fs.FS, path string) ([]byte, error) { - if UsesStackState() { - return nil, fs.ErrNotExist - } - return fs.ReadFile(fsys, path) -} - -// readLinkedPoolerUrl returns the linked pooler connection string from supabase/.temp, or from the -// environment in stack mode. -func readLinkedPoolerUrl(fsys fs.FS, path string) ([]byte, error) { - if UsesStackState() { - return []byte(os.Getenv(StackPoolerUrlEnv)), nil - } - return fs.ReadFile(fsys, path) -} - -type pathBuilder struct { - SupabaseDirPath string - ConfigPath string - GitIgnorePath string - TempDir string - ImportMapsDir string - ProjectRefPath string - PoolerUrlPath string - PostgresVersionPath string - GotrueVersionPath string - RestVersionPath string - StorageVersionPath string - StorageMigrationPath string - StudioVersionPath string - PgmetaVersionPath string - PoolerVersionPath string - RealtimeVersionPath string - EdgeRuntimeVersionPath string - LogflareVersionPath string - CliVersionPath string - CurrBranchPath string - SchemasDir string - MigrationsDir string - FunctionsDir string - FallbackImportMapPath string - FallbackEnvFilePath string - DbTestsDir string - CustomRolesPath string -} - -func NewPathBuilder(configPath string) pathBuilder { - if filepath.Base(configPath) == "." { - configPath = filepath.Join("supabase", "config.toml") - } - // TODO: make base path configurable from toml - base := filepath.Dir(configPath) - return pathBuilder{ - SupabaseDirPath: base, - ConfigPath: configPath, - GitIgnorePath: filepath.Join(base, ".gitignore"), - TempDir: filepath.Join(base, ".temp"), - ImportMapsDir: filepath.Join(base, ".temp", "import_maps"), - ProjectRefPath: filepath.Join(base, ".temp", "project-ref"), - PoolerUrlPath: filepath.Join(base, ".temp", "pooler-url"), - PostgresVersionPath: filepath.Join(base, ".temp", "postgres-version"), - GotrueVersionPath: filepath.Join(base, ".temp", "gotrue-version"), - RestVersionPath: filepath.Join(base, ".temp", "rest-version"), - StorageVersionPath: filepath.Join(base, ".temp", "storage-version"), - StorageMigrationPath: filepath.Join(base, ".temp", "storage-migration"), - EdgeRuntimeVersionPath: filepath.Join(base, ".temp", "edge-runtime-version"), - StudioVersionPath: filepath.Join(base, ".temp", "studio-version"), - PgmetaVersionPath: filepath.Join(base, ".temp", "pgmeta-version"), - PoolerVersionPath: filepath.Join(base, ".temp", "pooler-version"), - RealtimeVersionPath: filepath.Join(base, ".temp", "realtime-version"), - LogflareVersionPath: filepath.Join(base, ".temp", "logflare-version"), - CliVersionPath: filepath.Join(base, ".temp", "cli-latest"), - CurrBranchPath: filepath.Join(base, ".branches", "_current_branch"), - SchemasDir: filepath.Join(base, "schemas"), - MigrationsDir: filepath.Join(base, "migrations"), - FunctionsDir: filepath.Join(base, "functions"), - FallbackImportMapPath: filepath.Join(base, "functions", "import_map.json"), - FallbackEnvFilePath: filepath.Join(base, "functions", ".env"), - DbTestsDir: filepath.Join(base, "tests"), - CustomRolesPath: filepath.Join(base, "roles.sql"), - } -} - -func replaceImageTag(image string, tag string) string { - index := strings.IndexByte(image, ':') - return image[:index+1] + strings.TrimSpace(tag) -} - -func strToArr(v string) []string { - // Avoid returning [""] if v is empty - if len(v) == 0 { - return []string{} - } - return strings.Split(v, ",") -} - -func mapToEnv(input map[string]string) string { - var result []string - for k, v := range input { - kv := fmt.Sprintf("%s=%s", k, v) - result = append(result, kv) - } - return strings.Join(result, ",") -} - -func envToMap(input string) map[string]string { - env := strToArr(input) - result := make(map[string]string, len(env)) - for _, kv := range env { - if parts := strings.Split(kv, "="); len(parts) > 1 { - result[parts[0]] = parts[1] - } - } - return result -} - -func sha256Hmac(key, value string) string { - h := hmac.New(sha256.New, []byte(key)) - h.Write([]byte(value)) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/apps/cli-go/pkg/diff/diff.go b/apps/cli-go/pkg/diff/diff.go deleted file mode 100644 index e3a8e2d981..0000000000 --- a/apps/cli-go/pkg/diff/diff.go +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright 2022 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package diff - -import ( - "bytes" - "fmt" - "sort" - "strings" -) - -// A pair is a pair of values tracked for both the x and y side of a diff. -// It is typically a pair of line indexes. -type pair struct{ x, y int } - -// Diff returns an anchored diff of the two texts old and new -// in the “unified diff” format. If old and new are identical, -// Diff returns a nil slice (no output). -// -// Unix diff implementations typically look for a diff with -// the smallest number of lines inserted and removed, -// which can in the worst case take time quadratic in the -// number of lines in the texts. As a result, many implementations -// either can be made to run for a long time or cut off the search -// after a predetermined amount of work. -// -// In contrast, this implementation looks for a diff with the -// smallest number of “unique” lines inserted and removed, -// where unique means a line that appears just once in both old and new. -// We call this an “anchored diff” because the unique lines anchor -// the chosen matching regions. An anchored diff is usually clearer -// than a standard diff, because the algorithm does not try to -// reuse unrelated blank lines or closing braces. -// The algorithm also guarantees to run in O(n log n) time -// instead of the standard O(n²) time. -// -// Some systems call this approach a “patience diff,” named for -// the “patience sorting” algorithm, itself named for a solitaire card game. -// We avoid that name for two reasons. First, the name has been used -// for a few different variants of the algorithm, so it is imprecise. -// Second, the name is frequently interpreted as meaning that you have -// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm, -// when in fact the algorithm is faster than the standard one. -func Diff(oldName string, old []byte, newName string, new []byte) []byte { - if bytes.Equal(old, new) { - return nil - } - x := lines(old) - y := lines(new) - - // Print diff header. - var out bytes.Buffer - fmt.Fprintf(&out, "diff %s %s\n", oldName, newName) - fmt.Fprintf(&out, "--- %s\n", oldName) - fmt.Fprintf(&out, "+++ %s\n", newName) - - // Loop over matches to consider, - // expanding each match to include surrounding lines, - // and then printing diff chunks. - // To avoid setup/teardown cases outside the loop, - // tgs returns a leading {0,0} and trailing {len(x), len(y)} pair - // in the sequence of matches. - var ( - done pair // printed up to x[:done.x] and y[:done.y] - chunk pair // start lines of current chunk - count pair // number of lines from each side in current chunk - ctext []string // lines for current chunk - ) - for _, m := range tgs(x, y) { - if m.x < done.x { - // Already handled scanning forward from earlier match. - continue - } - - // Expand matching lines as far as possible, - // establishing that x[start.x:end.x] == y[start.y:end.y]. - // Note that on the first (or last) iteration we may (or definitely do) - // have an empty match: start.x==end.x and start.y==end.y. - start := m - for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] { - start.x-- - start.y-- - } - end := m - for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] { - end.x++ - end.y++ - } - - // Emit the mismatched lines before start into this chunk. - // (No effect on first sentinel iteration, when start = {0,0}.) - for _, s := range x[done.x:start.x] { - ctext = append(ctext, "-"+s) - count.x++ - } - for _, s := range y[done.y:start.y] { - ctext = append(ctext, "+"+s) - count.y++ - } - - // If we're not at EOF and have too few common lines, - // the chunk includes all the common lines and continues. - const C = 3 // number of context lines - if (end.x < len(x) || end.y < len(y)) && - (end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) { - for _, s := range x[start.x:end.x] { - ctext = append(ctext, " "+s) - count.x++ - count.y++ - } - done = end - continue - } - - // End chunk with common lines for context. - if len(ctext) > 0 { - n := min(end.x-start.x, C) - for _, s := range x[start.x : start.x+n] { - ctext = append(ctext, " "+s) - count.x++ - count.y++ - } - done = pair{start.x + n, start.y + n} - - // Format and emit chunk. - // Convert line numbers to 1-indexed. - // Special case: empty file shows up as 0,0 not 1,0. - if count.x > 0 { - chunk.x++ - } - if count.y > 0 { - chunk.y++ - } - fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y) - for _, s := range ctext { - out.WriteString(s) - } - count.x = 0 - count.y = 0 - ctext = ctext[:0] - } - - // If we reached EOF, we're done. - if end.x >= len(x) && end.y >= len(y) { - break - } - - // Otherwise start a new chunk. - chunk = pair{end.x - C, end.y - C} - for _, s := range x[chunk.x:end.x] { - ctext = append(ctext, " "+s) - count.x++ - count.y++ - } - done = end - } - - return out.Bytes() -} - -// lines returns the lines in the file x, including newlines. -// If the file does not end in a newline, one is supplied -// along with a warning about the missing newline. -func lines(x []byte) []string { - l := strings.SplitAfter(string(x), "\n") - if l[len(l)-1] == "" { - l = l[:len(l)-1] - } else { - // Treat last line as having a message about the missing newline attached, - // using the same text as BSD/GNU diff (including the leading backslash). - l[len(l)-1] += "\n\\ No newline at end of file\n" - } - return l -} - -// tgs returns the pairs of indexes of the longest common subsequence -// of unique lines in x and y, where a unique line is one that appears -// once in x and once in y. -// -// The longest common subsequence algorithm is as described in -// Thomas G. Szymanski, “A Special Case of the Maximal Common -// Subsequence Problem,” Princeton TR #170 (January 1975), -// available at https://research.swtch.com/tgs170.pdf. -func tgs(x, y []string) []pair { - // Count the number of times each string appears in a and b. - // We only care about 0, 1, many, counted as 0, -1, -2 - // for the x side and 0, -4, -8 for the y side. - // Using negative numbers now lets us distinguish positive line numbers later. - m := make(map[string]int) - for _, s := range x { - if c := m[s]; c > -2 { - m[s] = c - 1 - } - } - for _, s := range y { - if c := m[s]; c > -8 { - m[s] = c - 4 - } - } - - // Now unique strings can be identified by m[s] = -1+-4. - // - // Gather the indexes of those strings in x and y, building: - // xi[i] = increasing indexes of unique strings in x. - // yi[i] = increasing indexes of unique strings in y. - // inv[i] = index j such that x[xi[i]] = y[yi[j]]. - var xi, yi, inv []int - for i, s := range y { - if m[s] == -1+-4 { - m[s] = len(yi) - yi = append(yi, i) - } - } - for i, s := range x { - if j, ok := m[s]; ok && j >= 0 { - xi = append(xi, i) - inv = append(inv, j) - } - } - - // Apply Algorithm A from Szymanski's paper. - // In those terms, A = J = inv and B = [0, n). - // We add sentinel pairs {0,0}, and {len(x),len(y)} - // to the returned sequence, to help the processing loop. - J := inv - n := len(xi) - T := make([]int, n) - L := make([]int, n) - for i := range T { - T[i] = n + 1 - } - for i := range n { - k := sort.Search(n, func(k int) bool { - return T[k] >= J[i] - }) - T[k] = J[i] - L[i] = k + 1 - } - k := 0 - for _, v := range L { - if k < v { - k = v - } - } - seq := make([]pair, 2+k) - seq[1+k] = pair{len(x), len(y)} // sentinel at end - lastj := n - for i := n - 1; i >= 0; i-- { - if L[i] == k && J[i] < lastj { - seq[k] = pair{xi[i], yi[J[i]]} - k-- - } - } - seq[0] = pair{0, 0} // sentinel at start - return seq -} diff --git a/apps/cli-go/pkg/fetcher/gateway.go b/apps/cli-go/pkg/fetcher/gateway.go deleted file mode 100644 index 694882066b..0000000000 --- a/apps/cli-go/pkg/fetcher/gateway.go +++ /dev/null @@ -1,34 +0,0 @@ -package fetcher - -import ( - "net/http" - "strings" -) - -// NewServiceGateway returns a Fetcher for Supabase service-gateway calls (Kong, -// Storage, Auth, etc.). It deliberately omits http.Client.Timeout so streaming -// operations such as storage uploads are not capped under load. Connection -// setup is still bounded by the default transport's dial and TLS-handshake -// timeouts, and per-call deadlines should flow through the request context. -func NewServiceGateway(server, token string, overrides ...FetcherOption) *Fetcher { - opts := append([]FetcherOption{ - WithHTTPClient(&http.Client{}), - withAuthToken(token), - WithExpectedStatus(http.StatusOK), - }, overrides...) - return NewFetcher(server, opts...) -} - -func withAuthToken(token string) FetcherOption { - if strings.HasPrefix(token, "sb_") { - header := func(req *http.Request) { - req.Header.Add("apikey", token) - } - return WithRequestEditor(header) - } - header := func(req *http.Request) { - req.Header.Add("apikey", token) - req.Header.Add("Authorization", "Bearer "+token) - } - return WithRequestEditor(header) -} diff --git a/apps/cli-go/pkg/fetcher/http.go b/apps/cli-go/pkg/fetcher/http.go deleted file mode 100644 index 3ea4ac2fe2..0000000000 --- a/apps/cli-go/pkg/fetcher/http.go +++ /dev/null @@ -1,152 +0,0 @@ -package fetcher - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/url" - "slices" - "strings" - - "github.com/go-errors/errors" -) - -type Fetcher struct { - server string - client *http.Client - editors []RequestEditor - status []int -} - -type FetcherOption func(*Fetcher) - -func NewFetcher(server string, opts ...FetcherOption) *Fetcher { - api := &Fetcher{ - server: server, - client: http.DefaultClient, - } - for _, apply := range opts { - apply(api) - } - return api -} - -func WithHTTPClient(client *http.Client) FetcherOption { - return func(s *Fetcher) { - s.client = client - } -} - -func WithExpectedStatus(statusCode ...int) FetcherOption { - return func(s *Fetcher) { - s.status = statusCode - } -} - -func WithBearerToken(token string) FetcherOption { - addHeader := func(req *http.Request) { - req.Header.Add("Authorization", "Bearer "+token) - } - return WithRequestEditor(addHeader) -} - -func WithUserAgent(agent string) FetcherOption { - addHeader := func(req *http.Request) { - req.Header.Add("User-Agent", agent) - } - return WithRequestEditor(addHeader) -} - -func WithRequestEditor(fn RequestEditor) FetcherOption { - return func(s *Fetcher) { - s.editors = append(s.editors, fn) - } -} - -type RequestEditor func(req *http.Request) - -func (s *Fetcher) Send(ctx context.Context, method, path string, reqBody any, reqEditors ...RequestEditor) (*http.Response, error) { - body, ok := reqBody.(io.Reader) - if !ok && reqBody != nil { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - if err := enc.Encode(reqBody); err != nil { - return nil, errors.Errorf("failed to encode request body: %w", err) - } - reqEditors = append(reqEditors, func(req *http.Request) { - req.Header.Set("Content-Type", "application/json") - }) - body = &buf - } - // Creates request - req, err := http.NewRequestWithContext(ctx, method, s.server+path, body) - if err != nil { - return nil, errors.Errorf("failed to initialise http request: %w", err) - } - for _, apply := range s.editors { - apply(req) - } - for _, apply := range reqEditors { - apply(req) - } - // Sends request - resp, err := s.client.Do(req) - if err != nil { - if hint := localGatewayHint(s.server, err); len(hint) > 0 { - return nil, errors.Errorf("failed to execute http request: %w\n\n%s", err, hint) - } - return nil, errors.Errorf("failed to execute http request: %w", err) - } - if slices.Contains(s.status, resp.StatusCode) { - return resp, nil - } - // Reject unexpected status codes as error - if len(s.status) > 0 || resp.StatusCode >= http.StatusBadRequest { - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return resp, errors.Errorf("Error status %d: %w", resp.StatusCode, err) - } - return resp, errors.Errorf("Error status %d: %s", resp.StatusCode, data) - } - return resp, nil -} - -func localGatewayHint(server string, err error) string { - if err == nil { - return "" - } - parsed, parseErr := url.Parse(server) - if parseErr != nil { - return "" - } - host := parsed.Hostname() - if host != "127.0.0.1" && host != "localhost" && host != "::1" { - return "" - } - message := err.Error() - if !strings.Contains(message, "malformed HTTP response") && - !strings.Contains(message, "Client.Timeout exceeded while awaiting headers") && - !strings.Contains(message, "context deadline exceeded") { - return "" - } - port := parsed.Port() - if len(port) == 0 { - return "" - } - return "The local Supabase API gateway did not return a valid HTTP response. " + - "Another process may be listening on the configured API port " + port + ". " + - "Check the port with `lsof -nP -iTCP:" + port + " -sTCP:LISTEN`, then stop the conflicting process or set a different `api.port` in supabase/config.toml." -} - -func ParseJSON[T any](r io.ReadCloser) (T, error) { - defer r.Close() - var data T - dec := json.NewDecoder(r) - if err := dec.Decode(&data); err != nil { - return data, errors.Errorf("failed to parse response body: %w", err) - } - return data, nil -} diff --git a/apps/cli-go/pkg/fetcher/http_test.go b/apps/cli-go/pkg/fetcher/http_test.go deleted file mode 100644 index a24a0fa303..0000000000 --- a/apps/cli-go/pkg/fetcher/http_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package fetcher - -import ( - "context" - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSendSuggestsApiPortConflictForMalformedLocalResponse(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer listener.Close() - - done := make(chan struct{}) - go func() { - defer close(done) - conn, err := listener.Accept() - if err != nil { - return - } - defer conn.Close() - _, _ = conn.Write([]byte(`{"type":"Tier1","version":"1.0"}`)) - }() - - api := NewFetcher("http://" + listener.Addr().String()) - _, err = api.Send(context.Background(), "GET", "/storage/v1/bucket", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "malformed HTTP response") - assert.Contains(t, err.Error(), "Another process may be listening on the configured API port") - assert.Contains(t, err.Error(), "lsof -nP -iTCP:") - assert.Contains(t, err.Error(), "api.port") - - <-done -} diff --git a/apps/cli-go/pkg/function/api.go b/apps/cli-go/pkg/function/api.go deleted file mode 100644 index df44432405..0000000000 --- a/apps/cli-go/pkg/function/api.go +++ /dev/null @@ -1,53 +0,0 @@ -package function - -import ( - "context" - "io" - - "github.com/supabase/cli/pkg/api" -) - -type EdgeRuntimeAPI struct { - project string - client api.ClientWithResponses - eszip EszipBundler - maxJobs uint -} - -type FunctionDeployMetadata struct { - EntrypointPath string `json:"entrypoint_path"` - ImportMapPath *string `json:"import_map_path,omitempty"` - Name *string `json:"name,omitempty"` - StaticPatterns *[]string `json:"static_patterns,omitempty"` - VerifyJwt *bool `json:"verify_jwt,omitempty"` - SHA256 string `json:"sha256,omitempty"` -} - -type EszipBundler interface { - Bundle(ctx context.Context, slug, entrypoint, importMap string, staticFiles []string, output io.Writer) (FunctionDeployMetadata, error) -} - -func NewEdgeRuntimeAPI(project string, client api.ClientWithResponses, opts ...withOption) EdgeRuntimeAPI { - result := EdgeRuntimeAPI{client: client, project: project} - for _, apply := range opts { - apply(&result) - } - if result.maxJobs == 0 { - result.maxJobs = 1 - } - return result -} - -type withOption func(*EdgeRuntimeAPI) - -func WithBundler(bundler EszipBundler) withOption { - return func(era *EdgeRuntimeAPI) { - era.eszip = bundler - } -} - -func WithMaxJobs(maxJobs uint) withOption { - return func(era *EdgeRuntimeAPI) { - era.maxJobs = maxJobs - } -} diff --git a/apps/cli-go/pkg/function/batch.go b/apps/cli-go/pkg/function/batch.go deleted file mode 100644 index 70d0f4298a..0000000000 --- a/apps/cli-go/pkg/function/batch.go +++ /dev/null @@ -1,170 +0,0 @@ -package function - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" - "github.com/docker/go-units" - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/config" -) - -const ( - eszipContentType = "application/vnd.denoland.eszip" - maxRetries = 3 -) - -func (s *EdgeRuntimeAPI) UpsertFunctions(ctx context.Context, functionConfig config.FunctionConfig, filter ...func(string) bool) error { - policy := backoff.WithContext(backoff.WithMaxRetries(backoff.NewExponentialBackOff(), maxRetries), ctx) - result, err := backoff.RetryWithData(func() ([]api.FunctionResponseOutput, error) { - resp, err := s.client.V1ListAllFunctionsWithResponse(ctx, s.project) - if err != nil { - return nil, errors.Errorf("failed to list functions: %w", err) - } else if resp.JSON200 == nil { - err = errors.Errorf("unexpected list functions status %d: %s", resp.StatusCode(), string(resp.Body)) - if resp.StatusCode() < http.StatusInternalServerError { - err = &backoff.PermanentError{Err: err} - } - return nil, err - } - return *resp.JSON200, nil - }, policy) - if err != nil { - return err - } - policy.Reset() - slugToIndex := make(map[string]int, len(result)) - for i, f := range result { - slugToIndex[f.Slug] = i - } - var toUpdate api.BulkUpdateFunctionBody -OUTER: - for slug, function := range functionConfig { - if !function.Enabled { - fmt.Fprintln(os.Stderr, "Skipping disabled Function:", slug) - continue - } - for _, keep := range filter { - if !keep(slug) { - continue OUTER - } - } - var body bytes.Buffer - meta, err := s.eszip.Bundle(ctx, slug, function.Entrypoint, function.ImportMap, function.StaticFiles, &body) - if errors.Is(err, ErrNoDeploy) { - fmt.Fprintln(os.Stderr, "Skipping undeployable Function:", slug) - continue - } else if err != nil { - return err - } - meta.VerifyJwt = function.VerifyJWT - bodyHash := sha256.Sum256(body.Bytes()) - meta.SHA256 = hex.EncodeToString(bodyHash[:]) - // Skip if function has not changed - if i, exists := slugToIndex[slug]; exists && i >= 0 && - result[i].EzbrSha256 != nil && *result[i].EzbrSha256 == meta.SHA256 && - (function.VerifyJWT == nil || result[i].VerifyJwt != nil && *result[i].VerifyJwt == *function.VerifyJWT) { - fmt.Fprintln(os.Stderr, "No change found in Function:", slug) - continue - } - // Update if function already exists - upsert := func() (api.BulkUpdateFunctionBody, error) { - if _, exists := slugToIndex[slug]; exists { - return s.updateFunction(ctx, slug, meta, bytes.NewReader(body.Bytes())) - } - return s.createFunction(ctx, slug, meta, bytes.NewReader(body.Bytes())) - } - functionSize := units.HumanSize(float64(body.Len())) - fmt.Fprintf(os.Stderr, "Deploying Function: %s (script size: %s)\n", slug, functionSize) - result, err := backoff.RetryNotifyWithData(upsert, policy, func(err error, d time.Duration) { - if strings.Contains(err.Error(), "Duplicated function slug") { - slugToIndex[slug] = -1 - } - }) - if err != nil { - return err - } - toUpdate = append(toUpdate, result...) - policy.Reset() - } - if len(toUpdate) > 1 { - if err := backoff.Retry(func() error { - if resp, err := s.client.V1BulkUpdateFunctionsWithResponse(ctx, s.project, toUpdate); err != nil { - return errors.Errorf("failed to bulk update: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected bulk update status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return nil - }, policy); err != nil { - return err - } - } - return nil -} - -func (s *EdgeRuntimeAPI) updateFunction(ctx context.Context, slug string, meta FunctionDeployMetadata, body io.Reader) (api.BulkUpdateFunctionBody, error) { - resp, err := s.client.V1UpdateAFunctionWithBodyWithResponse(ctx, s.project, slug, &api.V1UpdateAFunctionParams{ - VerifyJwt: cast.BoolToStringPtr(meta.VerifyJwt), - ImportMapPath: meta.ImportMapPath, - EntrypointPath: &meta.EntrypointPath, - EzbrSha256: &meta.SHA256, - }, eszipContentType, body) - if err != nil { - return api.BulkUpdateFunctionBody{}, errors.Errorf("failed to update function: %w", err) - } else if resp.JSON200 == nil { - return api.BulkUpdateFunctionBody{}, errors.Errorf("unexpected update function status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return api.BulkUpdateFunctionBody{{ - Id: resp.JSON200.Id, - Name: resp.JSON200.Name, - Slug: resp.JSON200.Slug, - Version: resp.JSON200.Version, - EntrypointPath: resp.JSON200.EntrypointPath, - ImportMap: resp.JSON200.ImportMap, - ImportMapPath: resp.JSON200.ImportMapPath, - VerifyJwt: resp.JSON200.VerifyJwt, - Status: api.BulkUpdateFunctionBodyStatus(resp.JSON200.Status), - CreatedAt: &resp.JSON200.CreatedAt, - EzbrSha256: resp.JSON200.EzbrSha256, - }}, nil -} - -func (s *EdgeRuntimeAPI) createFunction(ctx context.Context, slug string, meta FunctionDeployMetadata, body io.Reader) (api.BulkUpdateFunctionBody, error) { - resp, err := s.client.V1CreateAFunctionWithBodyWithResponse(ctx, s.project, &api.V1CreateAFunctionParams{ - Slug: &slug, - Name: &slug, - VerifyJwt: cast.BoolToStringPtr(meta.VerifyJwt), - ImportMapPath: meta.ImportMapPath, - EntrypointPath: &meta.EntrypointPath, - EzbrSha256: &meta.SHA256, - }, eszipContentType, body) - if err != nil { - return api.BulkUpdateFunctionBody{}, errors.Errorf("failed to create function: %w", err) - } else if resp.JSON201 == nil { - return api.BulkUpdateFunctionBody{}, errors.Errorf("unexpected create function status %d: %s", resp.StatusCode(), string(resp.Body)) - } - return api.BulkUpdateFunctionBody{{ - Id: resp.JSON201.Id, - Name: resp.JSON201.Name, - Slug: resp.JSON201.Slug, - Version: resp.JSON201.Version, - EntrypointPath: resp.JSON201.EntrypointPath, - ImportMap: resp.JSON201.ImportMap, - ImportMapPath: resp.JSON201.ImportMapPath, - VerifyJwt: resp.JSON201.VerifyJwt, - Status: api.BulkUpdateFunctionBodyStatus(resp.JSON201.Status), - CreatedAt: &resp.JSON201.CreatedAt, - EzbrSha256: resp.JSON201.EzbrSha256, - }}, nil -} diff --git a/apps/cli-go/pkg/function/batch_test.go b/apps/cli-go/pkg/function/batch_test.go deleted file mode 100644 index 5bbd3a2fc1..0000000000 --- a/apps/cli-go/pkg/function/batch_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package function - -import ( - "context" - "errors" - "io" - "net/http" - "testing" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/config" -) - -type MockBundler struct { -} - -func (b *MockBundler) Bundle(ctx context.Context, slug, entrypoint, importMap string, staticFiles []string, output io.Writer) (FunctionDeployMetadata, error) { - if staticFiles == nil { - staticFiles = []string{} - } - return FunctionDeployMetadata{ - Name: &slug, - EntrypointPath: entrypoint, - ImportMapPath: &importMap, - StaticPatterns: &staticFiles, - }, nil -} - -func mockClient(t *testing.T) EdgeRuntimeAPI { - apiClient, err := api.NewClientWithResponses(mockApiHost) - require.NoError(t, err) - return NewEdgeRuntimeAPI(mockProject, *apiClient, func(era *EdgeRuntimeAPI) { - era.eszip = &MockBundler{} - }) -} - -const ( - mockApiHost = "https://api.supabase.com" - mockProject = "test-project" -) - -func TestUpsertFunctions(t *testing.T) { - client := mockClient(t) - - t.Run("deploys with bulk update", func(t *testing.T) { - // t.Cleanup(mockClock(100 * time.Millisecond)) - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{{Slug: "test-a"}}) - gock.New(mockApiHost). - Patch("/v1/projects/" + mockProject + "/functions/test-a"). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Slug: "test-a"}) - gock.New(mockApiHost). - Post("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusCreated). - JSON(api.FunctionResponseOutput{Slug: "test-b"}) - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - ReplyError(errors.New("network error")) - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusServiceUnavailable) - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON(api.V1BulkUpdateFunctionsResponse{}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test-a": {Enabled: true}, - "test-b": {Enabled: true}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("skips disabled and unchanged", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{{ - Slug: "test-a", - VerifyJwt: cast.Ptr(true), - EzbrSha256: cast.Ptr("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), - }}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test-a": {Enabled: true, VerifyJWT: cast.Ptr(true)}, - "test-b": {Enabled: false}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("skips unchanged function when verify_jwt is not configured", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{{ - Slug: "test-a", - VerifyJwt: cast.Ptr(false), - EzbrSha256: cast.Ptr("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), - }}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test-a": {Enabled: true}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("handles concurrent deploy", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{}) - gock.New(mockApiHost). - Post("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusBadRequest). - BodyString("Duplicated function slug") - gock.New(mockApiHost). - Patch("/v1/projects/" + mockProject + "/functions/test"). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Slug: "test"}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test": {Enabled: true}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("retries on network failure", func(t *testing.T) { - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - ReplyError(errors.New("network error")) - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusServiceUnavailable) - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusBadRequest) - // Run test - err := client.UpsertFunctions(context.Background(), nil) - // Check error - assert.ErrorContains(t, err, "unexpected list functions status 400:") - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) -} - -func TestCreateFunction(t *testing.T) { - client := mockClient(t) - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{}) - gock.New(mockApiHost). - Post("/v1/projects/" + mockProject + "/functions"). - ReplyError(errors.New("network error")) - gock.New(mockApiHost). - Post("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusServiceUnavailable) - gock.New(mockApiHost). - Post("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusCreated). - JSON(api.FunctionResponseOutput{Slug: "test"}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test": {Enabled: true}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) -} - -func TestUpdateFunction(t *testing.T) { - client := mockClient(t) - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON([]api.FunctionResponseOutput{{Slug: "test"}}) - gock.New(mockApiHost). - Patch("/v1/projects/" + mockProject + "/functions/test"). - ReplyError(errors.New("network error")) - gock.New(mockApiHost). - Patch("/v1/projects/" + mockProject + "/functions/test"). - Reply(http.StatusServiceUnavailable) - gock.New(mockApiHost). - Patch("/v1/projects/" + mockProject + "/functions/test"). - Reply(http.StatusOK). - JSON(api.FunctionResponseOutput{Slug: "test"}) - // Run test - err := client.UpsertFunctions(context.Background(), config.FunctionConfig{ - "test": {Enabled: true}, - }) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) -} diff --git a/apps/cli-go/pkg/function/bundle.go b/apps/cli-go/pkg/function/bundle.go deleted file mode 100644 index 599006aef8..0000000000 --- a/apps/cli-go/pkg/function/bundle.go +++ /dev/null @@ -1,153 +0,0 @@ -package function - -import ( - "context" - "fmt" - "io" - "io/fs" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/andybalholm/brotli" - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/cast" -) - -type nativeBundler struct { - tempDir string - fsys fs.FS - timeout time.Duration - denoVersion uint -} - -func NewNativeBundler(tempDir string, fsys fs.FS, opts ...func(*nativeBundler)) EszipBundler { - b := &nativeBundler{ - tempDir: tempDir, - fsys: fsys, - denoVersion: 2, - } - for _, apply := range opts { - apply(b) - } - return b -} - -func WithTimeout(timeout time.Duration) func(*nativeBundler) { - return func(b *nativeBundler) { - b.timeout = timeout - } -} - -func WithDenoVersion(version uint) func(*nativeBundler) { - return func(b *nativeBundler) { - b.denoVersion = version - } -} - -var ( - // Use a package private variable to allow testing without gosec complaining about G204 - edgeRuntimeBin = "edge-runtime" - BundleFlags = []string{} -) - -func (b *nativeBundler) Bundle(ctx context.Context, slug, entrypoint, importMap string, staticFiles []string, output io.Writer) (FunctionDeployMetadata, error) { - meta := NewMetadata(slug, entrypoint, importMap, staticFiles) - outputPath := filepath.Join(b.tempDir, slug+".eszip") - // TODO: make edge runtime write to stdout - args := []string{"bundle", "--entrypoint", entrypoint, "--output", outputPath} - if len(importMap) > 0 && !ShouldUseDenoJsonDiscovery(entrypoint, importMap) { - args = append(args, "--import-map", importMap) - } - for _, staticFile := range staticFiles { - args = append(args, "--static", staticFile) - } - args = append(args, BundleFlags...) - if b.timeout > 0 { - timeoutCtx, cancel := context.WithTimeout(ctx, b.timeout) - defer cancel() // release resources if command exits before timeout - ctx = timeoutCtx - } - cmd := exec.CommandContext(ctx, edgeRuntimeBin, args...) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - if fsys, ok := b.fsys.(fs.StatFS); ok && !ShouldUsePackageJsonDiscovery(entrypoint, importMap, fsys) { - cmd.Env = append(cmd.Environ(), "DENO_NO_PACKAGE_JSON=1") - } - if err := cmd.Run(); err != nil { - return meta, errors.Errorf("failed to bundle function: %w", err) - } - defer os.Remove(outputPath) - // Compress the output - eszipBytes, err := b.fsys.Open(outputPath) - if err != nil { - return meta, errors.Errorf("failed to open eszip: %w", err) - } - defer eszipBytes.Close() - return meta, Compress(eszipBytes, output) -} - -func ShouldUseDenoJsonDiscovery(entrypoint, importMap string) bool { - return isDeno(filepath.Base(importMap)) && filepath.Dir(importMap) == filepath.Dir(entrypoint) -} - -func ShouldUsePackageJsonDiscovery(entrypoint, importMap string, fsys fs.StatFS) bool { - if len(importMap) > 0 { - return false - } - packageJsonPath := filepath.Join(filepath.Dir(entrypoint), "package.json") - if _, err := fsys.Stat(packageJsonPath); errors.Is(err, os.ErrNotExist) { - return false - } - return true -} - -const compressedEszipMagicID = "EZBR" - -func Compress(r io.Reader, w io.Writer) error { - if _, err := fmt.Fprint(w, compressedEszipMagicID); err != nil { - return errors.Errorf("failed to append magic id: %w", err) - } - brw := brotli.NewWriter(w) - defer brw.Close() - if _, err := io.Copy(brw, r); err != nil { - return errors.Errorf("failed to compress eszip: %w", err) - } - return nil -} - -func NewMetadata(slug, entrypoint, importMap string, staticFiles []string) FunctionDeployMetadata { - meta := FunctionDeployMetadata{ - Name: &slug, - EntrypointPath: toFileURL(entrypoint), - } - if len(importMap) > 0 { - meta.ImportMapPath = cast.Ptr(toFileURL(importMap)) - } - files := make([]string, len(staticFiles)) - for i, sf := range staticFiles { - files[i] = toFileURL(sf) - } - meta.StaticPatterns = &files - return meta -} - -func toFileURL(hostPath string) string { - absHostPath, err := filepath.Abs(hostPath) - if err != nil { - return hostPath - } - // Convert to unix path because edge runtime only supports linux - unixPath := toUnixPath(absHostPath) - parsed := url.URL{Scheme: "file", Path: unixPath} - return parsed.String() -} - -func toUnixPath(absHostPath string) string { - prefix := filepath.VolumeName(absHostPath) - unixPath := filepath.ToSlash(absHostPath) - return strings.TrimPrefix(unixPath, prefix) -} diff --git a/apps/cli-go/pkg/function/bundle_test.go b/apps/cli-go/pkg/function/bundle_test.go deleted file mode 100644 index 1f16b87f47..0000000000 --- a/apps/cli-go/pkg/function/bundle_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package function - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "testing" - fs "testing/fstest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/cast" -) - -func TestMain(m *testing.M) { - // Setup mock edge runtime binary - if len(os.Args) > 1 && os.Args[1] == "bundle" { - if msg := os.Getenv("TEST_BUNDLE_ERROR"); len(msg) > 0 { - fmt.Fprintln(os.Stderr, msg) - os.Exit(1) - } - os.Exit(0) - } - // Run test suite - os.Exit(m.Run()) -} - -func TestBundleFunction(t *testing.T) { - cwd, err := os.Getwd() - require.NoError(t, err) - edgeRuntimeBin, err = os.Executable() - require.NoError(t, err) - - t.Run("creates eszip bundle", func(t *testing.T) { - var body bytes.Buffer - // Setup in-memory fs - fsys := fs.MapFS{ - "hello.eszip": &fs.MapFile{}, - } - // Setup mock bundler - bundler := nativeBundler{fsys: fsys} - // Run test - meta, err := bundler.Bundle( - context.Background(), - "hello", - "hello/index.ts", - "hello/deno.json", - []string{"hello/data.pdf"}, - &body, - ) - // Check error - assert.NoError(t, err) - assert.Equal(t, compressedEszipMagicID+";", body.String()) - assert.Equal(t, cast.Ptr("hello"), meta.Name) - entrypoint := fmt.Sprintf("file://%s/hello/index.ts", filepath.ToSlash(cwd)) - assert.Equal(t, entrypoint, meta.EntrypointPath) - importMap := fmt.Sprintf("file://%s/hello/deno.json", filepath.ToSlash(cwd)) - assert.Equal(t, &importMap, meta.ImportMapPath) - staticFile := fmt.Sprintf("file://%s/hello/data.pdf", filepath.ToSlash(cwd)) - assert.Equal(t, cast.Ptr([]string{staticFile}), meta.StaticPatterns) - assert.Nil(t, meta.VerifyJwt) - }) - - t.Run("ignores empty value", func(t *testing.T) { - var body bytes.Buffer - // Setup in-memory fs - fsys := fs.MapFS{ - "hello.eszip": &fs.MapFile{}, - } - // Setup mock bundler - bundler := nativeBundler{fsys: fsys} - // Run test - meta, err := bundler.Bundle( - context.Background(), - "hello", - "hello/index.ts", - "", - nil, - &body, - ) - // Check error - assert.NoError(t, err) - assert.Equal(t, compressedEszipMagicID+";", body.String()) - assert.Equal(t, cast.Ptr("hello"), meta.Name) - entrypoint := fmt.Sprintf("file://%s/hello/index.ts", filepath.ToSlash(cwd)) - assert.Equal(t, entrypoint, meta.EntrypointPath) - assert.Nil(t, meta.ImportMapPath) - assert.NotNil(t, meta.StaticPatterns) - assert.Nil(t, meta.VerifyJwt) - }) -} diff --git a/apps/cli-go/pkg/function/deno.go b/apps/cli-go/pkg/function/deno.go deleted file mode 100644 index 6c98b367b7..0000000000 --- a/apps/cli-go/pkg/function/deno.go +++ /dev/null @@ -1,181 +0,0 @@ -package function - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "io/fs" - "net/url" - "os" - "path" - "path/filepath" - "regexp" - "strings" - - "github.com/go-errors/errors" - "github.com/tidwall/jsonc" -) - -type ImportMap struct { - Imports map[string]string `json:"imports"` - Scopes map[string]map[string]string `json:"scopes"` - // Fallback reference for deno.json - ImportMap string `json:"importMap"` -} - -func (m *ImportMap) LoadAsDeno(imPath string, fsys fs.FS, opts ...func(string, io.Reader) error) error { - if err := m.Load(imPath, fsys, opts...); err != nil { - return err - } - if name := path.Base(imPath); isDeno(name) && m.IsReference() { - imPath = path.Join(path.Dir(imPath), m.ImportMap) - if err := m.Load(imPath, fsys, opts...); err != nil { - return err - } - } - return nil -} - -func isDeno(name string) bool { - return strings.EqualFold(name, "deno.json") || - strings.EqualFold(name, "deno.jsonc") -} - -func (m *ImportMap) IsReference() bool { - // Ref: https://github.com/denoland/deno/blob/main/cli/schemas/config-file.v1.json#L273 - return len(m.Imports) == 0 && len(m.Scopes) == 0 && len(m.ImportMap) > 0 -} - -func (m *ImportMap) Load(imPath string, fsys fs.FS, opts ...func(string, io.Reader) error) error { - data, err := fs.ReadFile(fsys, filepath.FromSlash(imPath)) - if err != nil { - return errors.Errorf("failed to load import map: %w", err) - } - if err := m.Parse(data); err != nil { - return err - } - if err := m.Resolve(imPath); err != nil { - return err - } - for _, apply := range opts { - if err := apply(imPath, bytes.NewReader(data)); err != nil { - return err - } - } - return nil -} - -func (m *ImportMap) Parse(data []byte) error { - data = jsonc.ToJSONInPlace(data) - decoder := json.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(&m); err != nil { - return errors.Errorf("failed to parse import map: %w", err) - } - return nil -} - -func (m *ImportMap) Resolve(imPath string) error { - // Resolve all paths relative to current file - for k, v := range m.Imports { - m.Imports[k] = resolveHostPath(imPath, v) - } - for module, mapping := range m.Scopes { - for k, v := range mapping { - m.Scopes[module][k] = resolveHostPath(imPath, v) - } - } - return nil -} - -func resolveHostPath(jsonPath, hostPath string) string { - // Leave absolute paths unchanged - if path.IsAbs(hostPath) { - return hostPath - } - // Leave URLs unchanged - if parsed, err := url.Parse(hostPath); err == nil && len(parsed.Scheme) > 0 { - return hostPath - } - resolved := path.Join(path.Dir(jsonPath), hostPath) - // Directory imports need to be suffixed with / - // Ref: https://deno.com/manual@v1.33.0/basics/import_maps - if strings.HasSuffix(hostPath, "/") { - resolved += "/" - } - // Relative imports must be prefixed with ./ or ../ - if !path.IsAbs(resolved) { - resolved = "./" + resolved - } - return resolved -} - -// Ref: https://regex101.com/r/DfBdJA/1 -var importPathPattern = regexp.MustCompile(`(?i)(?:import|export)\s+(?:type\s+)?(?:{[^{}]+}|.*?)\s*(?:from)?\s*['"](.*?)['"]|import\(\s*['"](.*?)['"]\)`) - -func (importMap *ImportMap) WalkImportPaths(srcPath string, readFile func(curr string, w io.Writer) error) error { - seen := map[string]struct{}{} - // DFS because it's more efficient to pop from end of array - q := make([]string, 1) - q[0] = srcPath - for len(q) > 0 { - curr := q[len(q)-1] - q = q[:len(q)-1] - // Assume no file is symlinked - if _, ok := seen[curr]; ok { - continue - } - seen[curr] = struct{}{} - // Read into memory for regex match later - var buf bytes.Buffer - if err := readFile(curr, &buf); errors.Is(err, os.ErrNotExist) { - fmt.Fprintln(os.Stderr, "WARN:", err) - continue - } else if err != nil { - return err - } - // Traverse all modules imported by the current source file - for _, matches := range importPathPattern.FindAllStringSubmatch(buf.String(), -1) { - if len(matches) < 3 { - continue - } - // Matches 'from' clause if present, else fallback to 'import' - mod := matches[1] - if len(mod) == 0 { - mod = matches[2] - } - mod = strings.TrimSpace(mod) - // Substitute kv from import map - substituted := false - for k, v := range importMap.Imports { - if strings.HasPrefix(mod, k) { - mod = v + mod[len(k):] - substituted = true - } - } - // Ignore URLs and directories, assuming no sloppy imports - // https://github.com/denoland/deno/issues/2506#issuecomment-2727635545 - if len(path.Ext(mod)) == 0 { - continue - } - // Deno import path must begin with one of these prefixes - if !isRelPath(mod) && !isAbsPath(mod) { - continue - } - if isRelPath(mod) && !substituted { - mod = path.Join(path.Dir(curr), mod) - } - // Cleans import path to help detect duplicates - q = append(q, path.Clean(mod)) - } - } - return nil -} - -func isRelPath(mod string) bool { - return strings.HasPrefix(mod, "./") || strings.HasPrefix(mod, "../") -} - -func isAbsPath(mod string) bool { - return strings.HasPrefix(mod, "/") -} diff --git a/apps/cli-go/pkg/function/deno_test.go b/apps/cli-go/pkg/function/deno_test.go deleted file mode 100644 index aa9677f5ad..0000000000 --- a/apps/cli-go/pkg/function/deno_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package function - -import ( - "embed" - "io" - "os" - "testing" - fs "testing/fstest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -//go:embed testdata -var testImports embed.FS - -type MockFS struct { - mock.Mock -} - -func (m *MockFS) ReadFile(srcPath string, w io.Writer) error { - _ = m.Called(srcPath) - data, err := testImports.ReadFile(srcPath) - if err != nil { - return err - } - if _, err := w.Write(data); err != nil { - return err - } - return nil -} - -func TestImportPaths(t *testing.T) { - t.Run("iterates all import paths", func(t *testing.T) { - // Setup in-memory fs - fsys := MockFS{} - fsys.On("ReadFile", "/modules/my-module.ts").Once() - fsys.On("ReadFile", "testdata/modules/imports.ts").Once() - fsys.On("ReadFile", "testdata/geometries/Geometries.js").Once() - // Run test - im := ImportMap{} - err := im.WalkImportPaths("testdata/modules/imports.ts", fsys.ReadFile) - // Check error - assert.NoError(t, err) - fsys.AssertExpectations(t) - }) - - t.Run("iterates with import map", func(t *testing.T) { - // Setup in-memory fs - fsys := MockFS{} - fsys.On("ReadFile", "/modules/my-module.ts").Once() - fsys.On("ReadFile", "testdata/modules/imports.ts").Once() - fsys.On("ReadFile", "testdata/geometries/Geometries.js").Once() - fsys.On("ReadFile", "testdata/shared/whatever.ts").Once() - fsys.On("ReadFile", "testdata/shared/mod.ts").Once() - fsys.On("ReadFile", "testdata/nested/index.ts").Once() - // Setup deno.json - im := ImportMap{Imports: map[string]string{ - "module-name/": "../shared/", - }} - assert.NoError(t, im.Resolve("testdata/modules/deno.json")) - // Run test - err := im.WalkImportPaths("testdata/modules/imports.ts", fsys.ReadFile) - // Check error - assert.NoError(t, err) - fsys.AssertExpectations(t) - }) - - t.Run("resolves legacy import map", func(t *testing.T) { - // Setup in-memory fs - fsys := MockFS{} - fsys.On("ReadFile", "/modules/my-module.ts").Once() - fsys.On("ReadFile", "testdata/modules/imports.ts").Once() - fsys.On("ReadFile", "testdata/geometries/Geometries.js").Once() - fsys.On("ReadFile", "testdata/shared/whatever.ts").Once() - fsys.On("ReadFile", "testdata/shared/mod.ts").Once() - fsys.On("ReadFile", "testdata/nested/index.ts").Once() - // Setup legacy import map - im := ImportMap{Imports: map[string]string{ - "module-name/": "./shared/", - }} - assert.NoError(t, im.Resolve("testdata/import_map.json")) - // Run test - err := im.WalkImportPaths("testdata/modules/imports.ts", fsys.ReadFile) - // Check error - assert.NoError(t, err) - fsys.AssertExpectations(t) - }) - - t.Run("iterates multiline import type statements", func(t *testing.T) { - // This test verifies that multiline import type statements are correctly parsed. - // The (?:type\s+)? group consumes the type keyword so braced imports hit the {[^{}]+} branch. - // Setup in-memory fs - fsys := MockFS{} - fsys.On("ReadFile", "testdata/modules/import_types.ts").Once() - fsys.On("ReadFile", "testdata/types/database.ts").Once() - // Run test - im := ImportMap{} - err := im.WalkImportPaths("testdata/modules/import_types.ts", fsys.ReadFile) - // Check error - assert.NoError(t, err) - fsys.AssertExpectations(t) - }) -} - -func TestResolveImports(t *testing.T) { - t.Run("resolves relative directory", func(t *testing.T) { - imPath := "supabase/functions/import_map.json" - // Setup in-memory fs - fsys := fs.MapFS{ - imPath: &fs.MapFile{Data: []byte(`{ - "imports": { - "abs/": "/tmp/", - "root": "../../common", - "parent": "../tests", - "child": "child/", - "missing": "../missing" - } - }`)}, - "/tmp/": &fs.MapFile{}, - "common": &fs.MapFile{}, - "supabase/tests": &fs.MapFile{}, - "supabase/functions/child": &fs.MapFile{}, - } - // Run test - resolved := ImportMap{} - err := resolved.Load(imPath, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, "/tmp/", resolved.Imports["abs/"]) - assert.Equal(t, "./common", resolved.Imports["root"]) - assert.Equal(t, "./supabase/tests", resolved.Imports["parent"]) - assert.Equal(t, "./supabase/functions/child/", resolved.Imports["child"]) - assert.Equal(t, "./supabase/missing", resolved.Imports["missing"]) - }) - - t.Run("resolves parent scopes", func(t *testing.T) { - imPath := "supabase/functions/import_map.json" - // Setup in-memory fs - fsys := fs.MapFS{ - imPath: &fs.MapFile{Data: []byte(`{ - "scopes": { - "my-scope": { - "my-mod": "https://deno.land" - } - } - }`)}, - } - // Run test - resolved := ImportMap{} - err := resolved.Load(imPath, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, "https://deno.land", resolved.Scopes["my-scope"]["my-mod"]) - }) -} - -func TestResolveDeno(t *testing.T) { - t.Run("resolves deno.json", func(t *testing.T) { - imPath := "supabase/functions/slug/deno.json" - // Setup in-memory fs - fsys := fs.MapFS{ - imPath: &fs.MapFile{Data: []byte(`{ - "imports": { - "@mod": "./mod.ts" - }, - "importMap": "../../import_map.json" - }`)}, - "supabase/functions/slug/mod.ts": &fs.MapFile{}, - } - // Run test - resolved := ImportMap{} - err := resolved.LoadAsDeno(imPath, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, "./supabase/functions/slug/mod.ts", resolved.Imports["@mod"]) - }) - - t.Run("resolves fallback imports", func(t *testing.T) { - imPath := "supabase/functions/slug/deno.json" - // Setup in-memory fs - fsys := fs.MapFS{ - imPath: &fs.MapFile{Data: []byte(`{ - "importMap": "../../import_map.json" - }`)}, - "supabase/import_map.json": &fs.MapFile{Data: []byte(`{ - "imports": { - "my-mod": "https://deno.land" - } - }`)}, - } - // Run test - resolved := ImportMap{} - err := resolved.LoadAsDeno(imPath, fsys) - // Check error - assert.NoError(t, err) - assert.Equal(t, "https://deno.land", resolved.Imports["my-mod"]) - }) - - t.Run("throws error on missing import", func(t *testing.T) { - imPath := "supabase/functions/slug/deno.jsonc" - // Setup in-memory fs - fsys := fs.MapFS{ - imPath: &fs.MapFile{Data: []byte(`{ - "importMap": "../../import_map.json" - }`)}, - } - // Run test - resolved := ImportMap{} - err := resolved.LoadAsDeno(imPath, fsys) - // Check error - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Empty(t, resolved.Imports) - assert.Empty(t, resolved.Scopes) - }) -} diff --git a/apps/cli-go/pkg/function/deploy.go b/apps/cli-go/pkg/function/deploy.go deleted file mode 100644 index 38458d6cdb..0000000000 --- a/apps/cli-go/pkg/function/deploy.go +++ /dev/null @@ -1,304 +0,0 @@ -package function - -import ( - "context" - "encoding/json" - "fmt" - "io" - "io/fs" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "strconv" - "time" - - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/queue" -) - -var ErrNoDeploy = errors.New("All Functions are up to date.") - -const deployRateLimitMaxRetries = 8 - -func (s *EdgeRuntimeAPI) Deploy(ctx context.Context, functionConfig config.FunctionConfig, fsys fs.FS) error { - if s.eszip != nil { - return s.UpsertFunctions(ctx, functionConfig) - } - remoteFunctions, err := s.listRemoteFunctionsForVerifyJwt(ctx, functionConfig) - if err != nil { - return err - } - // Convert all paths in functions config to relative when using api deploy - var toDeploy []FunctionDeployMetadata - for slug, fc := range functionConfig { - if !fc.Enabled { - fmt.Fprintln(os.Stderr, "Skipping disabled Function:", slug) - continue - } - meta := FunctionDeployMetadata{ - Name: &slug, - EntrypointPath: toRelPath(fc.Entrypoint), - ImportMapPath: cast.Ptr(toRelPath(fc.ImportMap)), - VerifyJwt: fc.VerifyJWT, - } - if meta.VerifyJwt == nil { - if remote, ok := remoteFunctions[slug]; ok { - meta.VerifyJwt = remote.VerifyJwt - } - } - files := make([]string, len(fc.StaticFiles)) - for i, sf := range fc.StaticFiles { - files[i] = toRelPath(sf) - } - meta.StaticPatterns = &files - toDeploy = append(toDeploy, meta) - } - if len(toDeploy) == 0 { - return errors.New(ErrNoDeploy) - } else if len(toDeploy) == 1 { - param := api.V1DeployAFunctionParams{Slug: toDeploy[0].Name} - _, err := s.upload(ctx, param, toDeploy[0], fsys) - return err - } - return s.bulkUpload(ctx, toDeploy, fsys) -} - -func (s *EdgeRuntimeAPI) listRemoteFunctionsForVerifyJwt(ctx context.Context, functionConfig config.FunctionConfig) (map[string]api.FunctionResponseOutput, error) { - needsRemote := false - for _, fc := range functionConfig { - if fc.Enabled && fc.VerifyJWT == nil { - needsRemote = true - break - } - } - if !needsRemote { - return nil, nil - } - resp, err := s.client.V1ListAllFunctionsWithResponse(ctx, s.project) - if err != nil { - return nil, errors.Errorf("failed to list functions: %w", err) - } else if resp.JSON200 == nil { - return nil, errors.Errorf("unexpected list functions status %d: %s", resp.StatusCode(), string(resp.Body)) - } - remoteFunctions := make(map[string]api.FunctionResponseOutput, len(*resp.JSON200)) - for _, function := range *resp.JSON200 { - remoteFunctions[function.Slug] = function - } - return remoteFunctions, nil -} - -func toRelPath(fp string) string { - if filepath.IsAbs(fp) { - if cwd, err := os.Getwd(); err == nil { - if relPath, err := filepath.Rel(cwd, fp); err == nil { - fp = relPath - } - } - } - return filepath.ToSlash(fp) -} - -func (s *EdgeRuntimeAPI) bulkUpload(ctx context.Context, toDeploy []FunctionDeployMetadata, fsys fs.FS) error { - jq := queue.NewJobQueue(s.maxJobs) - toUpdate := make(api.BulkUpdateFunctionBody, len(toDeploy)) - // Bulk uploads only write the bundle and bump the version remotely, so the new metadata - // exists nowhere but this client until the final bulk update request persists it. Bailing - // out on the first upload error strands that metadata and makes every subsequent deploy - // fail with a version conflict, so we keep going and report the errors at the end. - // - // Upload errors are recorded per function index rather than surfaced through the job - // queue's error channel, so they are reported in input order regardless of completion - // order under concurrent --jobs, matching the TS port. - uploadErrs := make([]error, len(toDeploy)) - var queueErrs []error - for i, meta := range toDeploy { - param := api.V1DeployAFunctionParams{ - Slug: meta.Name, - BundleOnly: cast.Ptr("true"), - } - bundle := func() error { - fmt.Fprintln(os.Stderr, "Deploying Function:", *meta.Name) - resp, err := s.upload(ctx, param, meta, fsys) - if err != nil { - uploadErrs[i] = err - return nil - } - toUpdate[i].Id = resp.Id - toUpdate[i].Name = resp.Name - toUpdate[i].Slug = resp.Slug - toUpdate[i].Version = resp.Version - toUpdate[i].EntrypointPath = resp.EntrypointPath - toUpdate[i].ImportMap = resp.ImportMap - toUpdate[i].ImportMapPath = resp.ImportMapPath - toUpdate[i].VerifyJwt = resp.VerifyJwt - toUpdate[i].Status = api.BulkUpdateFunctionBodyStatus(resp.Status) - toUpdate[i].CreatedAt = resp.CreatedAt - return nil - } - queueErrs = append(queueErrs, jq.Put(bundle)) - } - queueErrs = append(queueErrs, jq.Collect()) - bundleErr := errors.Join(append(uploadErrs, queueErrs...)...) - // Failed uploads leave their slot zero valued, ie. without a function id. - uploaded := make(api.BulkUpdateFunctionBody, 0, len(toUpdate)) - for _, f := range toUpdate { - if len(f.Id) > 0 { - uploaded = append(uploaded, f) - } - } - if len(uploaded) == 0 { - return bundleErr - } - for attempt := 0; ; attempt++ { - resp, err := s.client.V1BulkUpdateFunctionsWithResponse(ctx, s.project, uploaded) - if err != nil { - return errors.Join(bundleErr, errors.Errorf("failed to bulk update: %w", err)) - } else if resp.JSON200 != nil { - return bundleErr - } else if resp.StatusCode() != http.StatusTooManyRequests || attempt >= deployRateLimitMaxRetries { - return errors.Join(bundleErr, errors.Errorf("unexpected bulk update status %d: %s", resp.StatusCode(), string(resp.Body))) - } else if err := waitForRateLimit(ctx, responseHeaders(resp.HTTPResponse), attempt, "bulk updating functions"); err != nil { - return errors.Join(bundleErr, err) - } - } -} - -func (s *EdgeRuntimeAPI) upload(ctx context.Context, param api.V1DeployAFunctionParams, meta FunctionDeployMetadata, fsys fs.FS) (*api.DeployFunctionResponseOutput, error) { - for attempt := 0; ; attempt++ { - resp, err := s.uploadOnce(ctx, param, meta, fsys) - if resp != nil && resp.JSON201 != nil { - return resp.JSON201, nil - } else if err != nil { - return nil, err - } else if resp.StatusCode() != http.StatusTooManyRequests || attempt >= deployRateLimitMaxRetries { - return nil, errors.Errorf("unexpected deploy status %d: %s", resp.StatusCode(), string(resp.Body)) - } else if err := waitForRateLimit(ctx, responseHeaders(resp.HTTPResponse), attempt, "deploying function "+cast.Val(meta.Name, "")); err != nil { - return nil, err - } - } -} - -func (s *EdgeRuntimeAPI) uploadOnce(ctx context.Context, param api.V1DeployAFunctionParams, meta FunctionDeployMetadata, fsys fs.FS) (*api.V1DeployAFunctionResponse, error) { - body, w := io.Pipe() - form := multipart.NewWriter(w) - ctx, cancel := context.WithCancelCause(ctx) - go func() { - defer w.Close() - defer form.Close() - if err := writeForm(form, meta, fsys); err != nil { - // Since we are streaming files to the POST request body, any errors - // should be propagated to the request context to cancel the upload. - cancel(err) - } - }() - resp, err := s.client.V1DeployAFunctionWithBodyWithResponse(ctx, s.project, ¶m, form.FormDataContentType(), body) - if cause := context.Cause(ctx); cause != ctx.Err() { - return nil, cause - } else if err != nil { - return nil, errors.Errorf("failed to deploy function: %w", err) - } - return resp, nil -} - -func waitForRateLimit(ctx context.Context, headers http.Header, attempt int, action string) error { - delay := rateLimitDelay(headers, attempt) - fmt.Fprintf(os.Stderr, "Rate limit exceeded while %s. Retrying in %s.\n", action, delay.Round(time.Second)) - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func responseHeaders(resp *http.Response) http.Header { - if resp == nil { - return nil - } - return resp.Header -} - -func rateLimitDelay(headers http.Header, attempt int) time.Duration { - if delay, ok := parseRateLimitDelay(headers.Get("Retry-After")); ok { - return delay - } - if delay, ok := parseRateLimitDelay(headers.Get("X-RateLimit-Reset")); ok { - return delay - } - delay := time.Second << min(attempt, 5) - return delay -} - -func parseRateLimitDelay(value string) (time.Duration, bool) { - if len(value) == 0 { - return 0, false - } - if seconds, err := strconv.Atoi(value); err == nil { - return max(time.Duration(seconds)*time.Second, 0), true - } - if retryAt, err := http.ParseTime(value); err == nil { - return max(time.Until(retryAt), 0), true - } - return 0, false -} - -func writeForm(form *multipart.Writer, meta FunctionDeployMetadata, fsys fs.FS) error { - m, err := form.CreateFormField("metadata") - if err != nil { - return errors.Errorf("failed to create metadata: %w", err) - } - enc := json.NewEncoder(m) - if err := enc.Encode(meta); err != nil { - return errors.Errorf("failed to encode metadata: %w", err) - } - uploadAsset := func(srcPath string, r io.Reader) error { - fmt.Fprintf(os.Stderr, "Uploading asset (%s): %s\n", *meta.Name, srcPath) - f, err := form.CreateFormFile("file", srcPath) - if err != nil { - return errors.Errorf("failed to create form: %w", err) - } - if _, err := io.Copy(f, r); err != nil { - return errors.Errorf("failed to write form: %w", err) - } - return nil - } - addFile := func(srcPath string, w io.Writer) error { - f, err := fsys.Open(filepath.FromSlash(srcPath)) - if err != nil { - return errors.Errorf("failed to read file: %w", err) - } - defer f.Close() - if fi, err := f.Stat(); err != nil { - return errors.Errorf("failed to stat file: %w", err) - } else if fi.IsDir() { - return errors.New("file path is a directory: " + srcPath) - } - r := io.TeeReader(f, w) - return uploadAsset(srcPath, r) - } - // Add import map - importMap := ImportMap{} - if imPath := cast.Val(meta.ImportMapPath, ""); len(imPath) > 0 { - if err := importMap.LoadAsDeno(imPath, fsys, uploadAsset); err != nil { - return err - } - } - // Add static files - patterns := config.Glob(cast.Val(meta.StaticPatterns, []string{})) - files, err := patterns.Files(fsys) - if err != nil { - fmt.Fprintln(os.Stderr, "WARN:", err) - } - for _, sfPath := range files { - if err := addFile(sfPath, io.Discard); err != nil { - return err - } - } - return importMap.WalkImportPaths(meta.EntrypointPath, addFile) -} diff --git a/apps/cli-go/pkg/function/deploy_test.go b/apps/cli-go/pkg/function/deploy_test.go deleted file mode 100644 index 06821e3ac1..0000000000 --- a/apps/cli-go/pkg/function/deploy_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package function - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "mime/multipart" - "net/http" - "os" - "path" - "strings" - "testing" - fs "testing/fstest" - "time" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/api" - "github.com/supabase/cli/pkg/cast" - "github.com/supabase/cli/pkg/config" -) - -func assertFormEqual(t *testing.T, actual []byte) { - snapshot := path.Join("testdata", path.Base(t.Name())+".form") - expected, err := testImports.ReadFile(snapshot) - if errors.Is(err, os.ErrNotExist) { - assert.NoError(t, os.WriteFile(snapshot, actual, 0600)) - } - assert.Equal(t, string(expected), string(actual)) -} - -// captureBody records the request body without consuming it, so tests can assert -// on the exact payload sent to a mocked endpoint. -func captureBody(out *[]byte) gock.MatchFunc { - return func(req *http.Request, _ *gock.Request) (bool, error) { - if req.Body == nil { - return true, nil - } - body, err := io.ReadAll(req.Body) - if err != nil { - return false, err - } - req.Body = io.NopCloser(bytes.NewReader(body)) - *out = body - return true, nil - } -} - -func mockFunctionList(functions ...api.FunctionResponseOutput) { - gock.New(mockApiHost). - Get("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON(functions) -} - -func TestWriteForm(t *testing.T) { - t.Run("writes import map", func(t *testing.T) { - var buf bytes.Buffer - form := multipart.NewWriter(&buf) - require.NoError(t, form.SetBoundary("test")) - // Setup in-memory fs - fsys := testImports - // Run test - err := writeForm(form, FunctionDeployMetadata{ - Name: cast.Ptr("nested"), - VerifyJwt: cast.Ptr(true), - EntrypointPath: "testdata/nested/index.ts", - ImportMapPath: cast.Ptr("testdata/nested/deno.json"), - StaticPatterns: cast.Ptr([]string{"testdata/*/*.js"}), - }, fsys) - // Check error - assert.NoError(t, err) - assertFormEqual(t, buf.Bytes()) - }) - - t.Run("throws error on missing file", func(t *testing.T) { - var buf bytes.Buffer - form := multipart.NewWriter(&buf) - require.NoError(t, form.SetBoundary("test")) - // Setup in-memory fs - fsys := fs.MapFS{} - // Run test - err := writeForm(form, FunctionDeployMetadata{ - ImportMapPath: cast.Ptr("testdata/import_map.json"), - }, fsys) - // Check error - assert.ErrorIs(t, err, os.ErrNotExist) - }) - - t.Run("throws error on directory path", func(t *testing.T) { - var buf bytes.Buffer - form := multipart.NewWriter(&buf) - require.NoError(t, form.SetBoundary("test")) - // Setup in-memory fs - fsys := testImports - // Run test - err := writeForm(form, FunctionDeployMetadata{ - StaticPatterns: cast.Ptr([]string{"testdata"}), - }, fsys) - // Check error - assert.ErrorContains(t, err, "file path is a directory:") - }) -} - -func TestDeployAll(t *testing.T) { - apiClient, err := api.NewClientWithResponses(mockApiHost) - require.NoError(t, err) - client := NewEdgeRuntimeAPI(mockProject, *apiClient) - - t.Run("deploys single slug", func(t *testing.T) { - c := config.FunctionConfig{"demo": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }} - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "demo"). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("retries single slug after rate limit", func(t *testing.T) { - c := config.FunctionConfig{"demo": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }} - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "demo"). - Reply(http.StatusTooManyRequests). - SetHeader("Retry-After", "0"). - JSON(map[string]string{"message": "Too Many Requests"}) - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "demo"). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("deploys multiple slugs", func(t *testing.T) { - c := config.FunctionConfig{ - "test-ts": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }, - "test-js": { - Enabled: true, - Entrypoint: "testdata/geometries/Geometries.js", - }, - } - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - for slug := range c { - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", slug). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{Id: slug}) - } - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON(api.BulkUpdateFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("retries bulk update after rate limit reset", func(t *testing.T) { - c := config.FunctionConfig{ - "test-ts": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }, - "test-js": { - Enabled: true, - Entrypoint: "testdata/geometries/Geometries.js", - }, - } - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - for slug := range c { - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", slug). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{Id: slug}) - } - gock.New(mockApiHost). - Put("/v1/projects/"+mockProject+"/functions"). - Reply(http.StatusTooManyRequests). - SetHeader("X-RateLimit-Reset", "0"). - JSON(map[string]string{"message": "Too Many Requests"}) - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusOK). - JSON(api.BulkUpdateFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("bulk updates successful uploads when one fails", func(t *testing.T) { - c := config.FunctionConfig{ - "test-ts": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }, - "test-js": { - Enabled: true, - Entrypoint: "testdata/geometries/Geometries.js", - }, - } - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "test-ts"). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{Id: "test-ts", Name: "test-ts", Slug: "test-ts"}) - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "test-js"). - Reply(http.StatusConflict). - JSON(map[string]string{"message": "deployment already exists"}) - var bulkBody []byte - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - AddMatcher(captureBody(&bulkBody)). - Reply(http.StatusOK). - JSON(api.BulkUpdateFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.ErrorContains(t, err, "unexpected deploy status 409") - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - var toUpdate api.BulkUpdateFunctionBody - require.NoError(t, json.Unmarshal(bulkBody, &toUpdate)) - require.Len(t, toUpdate, 1) - assert.Equal(t, "test-ts", toUpdate[0].Slug) - }) - - t.Run("skips bulk update when all uploads fail", func(t *testing.T) { - c := config.FunctionConfig{ - "test-ts": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }, - "test-js": { - Enabled: true, - Entrypoint: "testdata/geometries/Geometries.js", - }, - } - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - for slug := range c { - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", slug). - Reply(http.StatusConflict). - JSON(map[string]string{"message": "deployment already exists"}) - } - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.ErrorContains(t, err, "unexpected deploy status 409") - // No bulk update is mocked, so any PUT would show up as unmatched - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("reports upload and bulk update failures together", func(t *testing.T) { - c := config.FunctionConfig{ - "test-ts": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }, - "test-js": { - Enabled: true, - Entrypoint: "testdata/geometries/Geometries.js", - }, - } - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "test-ts"). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{Id: "test-ts", Name: "test-ts", Slug: "test-ts"}) - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "test-js"). - Reply(http.StatusConflict). - JSON(map[string]string{"message": "deployment already exists"}) - gock.New(mockApiHost). - Put("/v1/projects/" + mockProject + "/functions"). - Reply(http.StatusBadRequest). - JSON(map[string]string{"message": "bulk update rejected"}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.ErrorContains(t, err, "unexpected deploy status 409") - assert.ErrorContains(t, err, "unexpected bulk update status 400") - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("reports concurrent upload failures in input order", func(t *testing.T) { - client2 := NewEdgeRuntimeAPI(mockProject, *apiClient, WithMaxJobs(2)) - toDeploy := []FunctionDeployMetadata{ - { - Name: cast.Ptr("first-fn"), - EntrypointPath: "testdata/shared/whatever.ts", - }, - { - Name: cast.Ptr("second-fn"), - EntrypointPath: "testdata/geometries/Geometries.js", - }, - } - fsys := testImports - // Setup mock api - defer gock.OffAll() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "first-fn"). - Reply(http.StatusConflict). - Delay(500 * time.Millisecond). - JSON(map[string]string{"message": "first failed"}) - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "second-fn"). - Reply(http.StatusConflict). - JSON(map[string]string{"message": "second failed"}) - // Run test - err := client2.bulkUpload(context.Background(), toDeploy, fsys) - // Check error - require.Error(t, err) - // JSON replies are newline-terminated by json.Encoder, so filter out the - // resulting blank lines between joined errors before counting entries. - var lines []string - for _, line := range strings.Split(err.Error(), "\n") { - if len(line) > 0 { - lines = append(lines, line) - } - } - require.Len(t, lines, 2) - assert.Contains(t, lines[0], "first failed") - assert.Contains(t, lines[1], "second failed") - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("preserves remote verify_jwt when not configured", func(t *testing.T) { - c := config.FunctionConfig{"demo": { - Enabled: true, - Entrypoint: "testdata/shared/whatever.ts", - }} - // Setup in-memory fs - fsys := testImports - // Setup mock api - defer gock.OffAll() - mockFunctionList(api.FunctionResponseOutput{ - Id: "demo", - Name: "demo", - Slug: "demo", - VerifyJwt: cast.Ptr(false), - }) - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "demo"). - BodyString(`"verify_jwt":false`). - Reply(http.StatusCreated). - JSON(api.DeployFunctionResponseOutput{}) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("throws error on network failure", func(t *testing.T) { - errNetwork := errors.New("network") - c := config.FunctionConfig{"demo": {Enabled: true}} - // Setup in-memory fs - fsys := fs.MapFS{} - // Setup mock api - defer gock.OffAll() - mockFunctionList() - gock.New(mockApiHost). - Post("/v1/projects/"+mockProject+"/functions/deploy"). - MatchParam("slug", "demo"). - ReplyError(errNetwork) - // Run test - err := client.Deploy(context.Background(), c, fsys) - // Check error - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/pkg/function/testdata/geometries/Geometries.js b/apps/cli-go/pkg/function/testdata/geometries/Geometries.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/cli-go/pkg/function/testdata/modules/import_types.ts b/apps/cli-go/pkg/function/testdata/modules/import_types.ts deleted file mode 100644 index d285ab0ee8..0000000000 --- a/apps/cli-go/pkg/function/testdata/modules/import_types.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Test file for multiline import type statements -// This pattern requires (?:type\s+)? to route braced imports into the {[^{}]+} branch - -// Multiline import type - should be matched by the regex -import type { - Database, - Json -} from '../types/database.ts' - -// Single line import type - should also work -import type { Database as DB } from '../types/database.ts' - -// Re-export type to verify export pattern -export type { Database } from '../types/database.ts' - -// Multiline export type -export type { - Json -} from '../types/database.ts' - -// Non-braced default type import - exercises the .*? branch on single-line -import type Database from '../types/database.ts' diff --git a/apps/cli-go/pkg/function/testdata/modules/imports.ts b/apps/cli-go/pkg/function/testdata/modules/imports.ts deleted file mode 100644 index d4d8cda7b3..0000000000 --- a/apps/cli-go/pkg/function/testdata/modules/imports.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Component -} from '@angular2/core'; -import defaultMember from "module-name"; -import * as name from "module-name "; -import { member } from " module-name"; -import { member as alias } from "module-name"; -import { member1 , member2 } from "module-name"; -import { member1 , member2 as alias2 , member3 as alias3 } from "module-name"; -import { - Component -} from '@angular2/core'; -import defaultMember from "$module-name"; -import defaultMember, { member, member } from "module-name"; -import defaultMember, * as name from "module-name"; - -import * as name from "module-name " -import { member } from " module-name" -import { member as alias } from "module-name" -import { member1 , member2 } from "module-name" -import { member1 , member2 as alias2 , member3 as alias3 } from "module-name" -import { - Component -} from '@angular2/core' -import defaultMember from "$module-name" -import defaultMember, { member, member } from "module-name" -import defaultMember, * as name from "module-name" - -import "module-name"; -import React from "react" -import { Field } from "redux-form" -import "module-name"; - -import { - PlaneBufferGeometry, - OctahedronGeometry, - TorusBufferGeometry -} from '../geometries/Geometries.js'; - -import { - PlaneBufferGeometry, - OctahedronGeometry, - TorusBufferGeometry -} from '../geometries/Geometries.js' - -import("module-name/whatever.ts"); -import("module-name/whatever.ts") - -import { Field } from "redux-form"; -import MultiContentListView from "./views/ListView"; -import MultiContentAddView from "./views/AddView"; -import MultiContentEditView from "./views/EditView"; - -import { Field } from "redux-form" -import MultiContentListView from "./views/ListView" -import MultiContentAddView from "./views/AddView" -import MultiContentEditView from "./views/EditView" - - -// JSX-like snippets are comments so this .ts fixture remains valid syntax. -// -// - -// *Add all needed dependency to this module -// *app requires those import modules to function - - -/** -* - *Add all needed dependency to this module - *app requires those import modules to function - * -**/ - -let modules = []; - - -import defaultExport from "module-name"; -import * as name from "module-name"; -import { export1 } from "module-name"; -import { export1 as alias1 } from "module-name"; -import { default as alias } from "module-name"; -import { export1, export2 } from "module-name"; -import { export1, export2 as alias2, /* … */ } from "module-name"; -import { "string name" as alias } from "module-name"; -import defaultExport, { export1, /* … */ } from "module-name"; -import defaultExport, * as name from "module-name"; -import "module-name"; -import { "a-b" as a } from "/modules/my-module.ts"; -import myDefault from "/modules/my-module.ts"; -import myDefault, * as myModule from "/modules/my-module.ts"; diff --git a/apps/cli-go/pkg/function/testdata/nested/deno.json b/apps/cli-go/pkg/function/testdata/nested/deno.json deleted file mode 100644 index bfba6fbb2c..0000000000 --- a/apps/cli-go/pkg/function/testdata/nested/deno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "module": "jsr:@supabase/functions-js/edge-runtime.d.ts" - } -} diff --git a/apps/cli-go/pkg/function/testdata/nested/index.ts b/apps/cli-go/pkg/function/testdata/nested/index.ts deleted file mode 100644 index b0539a467f..0000000000 --- a/apps/cli-go/pkg/function/testdata/nested/index.ts +++ /dev/null @@ -1 +0,0 @@ -import "module"; diff --git a/apps/cli-go/pkg/function/testdata/shared/whatever.ts b/apps/cli-go/pkg/function/testdata/shared/whatever.ts deleted file mode 100644 index 879e79cb31..0000000000 --- a/apps/cli-go/pkg/function/testdata/shared/whatever.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as _ from "../nested/index.ts"; - -export * from "./mod.ts"; diff --git a/apps/cli-go/pkg/function/testdata/types/database.ts b/apps/cli-go/pkg/function/testdata/types/database.ts deleted file mode 100644 index 4c49d8e2d0..0000000000 --- a/apps/cli-go/pkg/function/testdata/types/database.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type Database = { - public: { - Tables: { - users: { - Row: { id: string; name: string } - } - } - } -} - -export type Json = string | number | boolean | null | { [key: string]: Json } | Json[] diff --git a/apps/cli-go/pkg/function/testdata/writes_import_map.form b/apps/cli-go/pkg/function/testdata/writes_import_map.form deleted file mode 100644 index 174a55f6b0..0000000000 --- a/apps/cli-go/pkg/function/testdata/writes_import_map.form +++ /dev/null @@ -1,25 +0,0 @@ ---test -Content-Disposition: form-data; name="metadata" - -{"entrypoint_path":"testdata/nested/index.ts","import_map_path":"testdata/nested/deno.json","name":"nested","static_patterns":["testdata/*/*.js"],"verify_jwt":true} - ---test -Content-Disposition: form-data; name="file"; filename="testdata/nested/deno.json" -Content-Type: application/octet-stream - -{ - "imports": { - "module": "jsr:@supabase/functions-js/edge-runtime.d.ts" - } -} - ---test -Content-Disposition: form-data; name="file"; filename="testdata/geometries/Geometries.js" -Content-Type: application/octet-stream - - ---test -Content-Disposition: form-data; name="file"; filename="testdata/nested/index.ts" -Content-Type: application/octet-stream - -import "module"; diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod deleted file mode 100644 index 44a3f2acfd..0000000000 --- a/apps/cli-go/pkg/go.mod +++ /dev/null @@ -1,55 +0,0 @@ -module github.com/supabase/cli/pkg - -go 1.26.0 - -require ( - github.com/BurntSushi/toml v1.6.0 - github.com/andybalholm/brotli v1.2.3 - github.com/cenkalti/backoff/v4 v4.3.0 - github.com/docker/go-units v0.5.0 - github.com/ecies/go/v2 v2.0.11 - github.com/go-errors/errors v1.5.1 - github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/h2non/gock v1.2.0 - github.com/jackc/pgconn v1.14.3 - github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 - github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 - github.com/jackc/pgproto3/v2 v2.3.3 - github.com/jackc/pgtype v1.14.4 - github.com/jackc/pgx/v4 v4.18.3 - github.com/joho/godotenv v1.5.1 - github.com/oapi-codegen/nullable v1.2.0 - github.com/oapi-codegen/runtime v1.7.0 - github.com/spf13/afero v1.15.0 - github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.1 - github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.41.0 - google.golang.org/grpc v1.83.2 -) - -require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/ethereum/go-ethereum v1.17.0 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgio v1.0.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/objx v0.5.3 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect -) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum deleted file mode 100644 index b5fa53c40b..0000000000 --- a/apps/cli-go/pkg/go.sum +++ /dev/null @@ -1,298 +0,0 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ= -github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ecies/go/v2 v2.0.11 h1:xYhtMdLiqNi02oLirFmLyNbVXw6250h3WM6zJryQdiM= -github.com/ecies/go/v2 v2.0.11/go.mod h1:LPRzoefP0Tam+1uesQOq3Gtb6M2OwlFUnXBTtBAKfDQ= -github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= -github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= -github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= -github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= -github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= -github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= -github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= -github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= -github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= -github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= -github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= -github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/oapi-codegen/nullable v1.2.0 h1:VflFkDW980KhBPiFF7nWSyjg+r4Obqj8lXipV0UkP5w= -github.com/oapi-codegen/nullable v1.2.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= -github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= -github.com/tidwall/jsonc v0.3.3/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/apps/cli-go/pkg/migration/apply.go b/apps/cli-go/pkg/migration/apply.go deleted file mode 100644 index e40b58be2e..0000000000 --- a/apps/cli-go/pkg/migration/apply.go +++ /dev/null @@ -1,77 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/jackc/pgx/v4" -) - -var ( - ErrMissingRemote = errors.New("Found local migration files to be inserted before the last migration on remote database.") - ErrMissingLocal = errors.New("Remote migration versions not found in local migrations directory.") -) - -// Find unapplied local migrations older than the latest migration on -// remote, and remote migrations that are missing from local. -func FindPendingMigrations(localMigrations, remoteMigrations []string) ([]string, error) { - var unapplied, missing []string - i, j := 0, 0 - for i < len(remoteMigrations) && j < len(localMigrations) { - remote := remoteMigrations[i] - filename := filepath.Base(localMigrations[j]) - // Check if migration has been applied before, LoadLocalMigrations guarantees a match - local := migrateFilePattern.FindStringSubmatch(filename)[1] - if remote == local { - j++ - i++ - } else if remote < local { - missing = append(missing, remote) - i++ - } else { - // Include out-of-order local migrations - unapplied = append(unapplied, localMigrations[j]) - j++ - } - } - // Ensure all remote versions exist on local - if j == len(localMigrations) { - missing = append(missing, remoteMigrations[i:]...) - } - if len(missing) > 0 { - return missing, errors.New(ErrMissingLocal) - } - // Enforce migrations are applied in chronological order by default - if len(unapplied) > 0 { - return unapplied, errors.New(ErrMissingRemote) - } - pending := localMigrations[len(remoteMigrations):] - return pending, nil -} - -func ApplyMigrations(ctx context.Context, pending []string, conn *pgx.Conn, fsys fs.FS) error { - if len(pending) > 0 { - if err := CreateMigrationTable(ctx, conn); err != nil { - return err - } - } - for _, path := range pending { - filename := filepath.Base(path) - fmt.Fprintf(os.Stderr, "Applying migration %s...\n", filename) - // Reset all connection settings that might have been modified by another statement on the same connection - // eg: `SELECT pg_catalog.set_config('search_path', '', false);` - if _, err := conn.Exec(ctx, "RESET ALL"); err != nil { - return errors.Errorf("failed to reset connection state: %v", err) - } - if migration, err := NewMigrationFromFile(path, fsys); err != nil { - return err - } else if err := migration.ExecBatch(ctx, conn); err != nil { - return err - } - } - return nil -} diff --git a/apps/cli-go/pkg/migration/apply_test.go b/apps/cli-go/pkg/migration/apply_test.go deleted file mode 100644 index e6df97721f..0000000000 --- a/apps/cli-go/pkg/migration/apply_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package migration - -import ( - "context" - "embed" - "os" - "testing" - fs "testing/fstest" - - "github.com/jackc/pgerrcode" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestPendingMigrations(t *testing.T) { - t.Run("finds pending migrations", func(t *testing.T) { - local := []string{ - "20221201000000_test.sql", - "20221201000001_test.sql", - "20221201000002_test.sql", - "20221201000003_test.sql", - } - remote := []string{ - "20221201000000", - "20221201000001", - } - // Run test - pending, err := FindPendingMigrations(local, remote) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, local[2:], pending) - }) - - t.Run("throws error on missing local migration", func(t *testing.T) { - local := []string{} - remote := []string{"0"} - // Run test - pending, err := FindPendingMigrations(local, remote) - // Check error - assert.ErrorIs(t, err, ErrMissingLocal) - assert.ElementsMatch(t, remote, pending) - }) - - t.Run("throws error on missing remote version", func(t *testing.T) { - local := []string{ - "0_test.sql", - "1_test.sql", - } - remote := []string{"1"} - // Run test - pending, err := FindPendingMigrations(local, remote) - // Check error - assert.ErrorIs(t, err, ErrMissingRemote) - assert.ElementsMatch(t, local[:1], pending) - }) - - t.Run("throws error on out-of-order remote migrations", func(t *testing.T) { - local := []string{ - "20221201000000_test.sql", - "20221201000001_test.sql", - "20221201000002_test.sql", - "20221201000003_test.sql", - "20221201000004_test.sql", - } - remote := []string{ - "20221201000002", - "20221201000004", - } - // Run test - missing, err := FindPendingMigrations(local, remote) - // Check error - assert.ErrorIs(t, err, ErrMissingRemote) - assert.ElementsMatch(t, []string{local[0], local[1], local[3]}, missing) - }) - - t.Run("throws error on out-of-order local migrations", func(t *testing.T) { - local := []string{ - "20221201000000_test.sql", - "20221201000002_test.sql", - } - remote := []string{ - "20221201000000", - "20221201000001", - "20221201000002", - "20221201000003", - "20221201000004", - } - // Run test - missing, err := FindPendingMigrations(local, remote) - // Check error - assert.ErrorIs(t, err, ErrMissingLocal) - assert.ElementsMatch(t, []string{remote[1], remote[3], remote[4]}, missing) - }) -} - -var ( - //go:embed testdata - testMigrations embed.FS - //go:embed testdata/0_schema.sql - testSchema string -) - -func TestApplyMigrations(t *testing.T) { - pending := []string{"testdata/0_schema.sql"} - - t.Run("applies migrations and appends history", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(testSchema). - Reply("CREATE SCHEMA"). - Query(INSERT_MIGRATION_VERSION, "0", "schema", []string{testSchema}). - Reply("INSERT 0 1") - // Run test - err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on history failure", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(SET_LOCK_TIMEOUT). - Query(CREATE_VERSION_SCHEMA). - Reply("CREATE SCHEMA"). - Query(CREATE_VERSION_TABLE). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations"). - Query(ADD_STATEMENTS_COLUMN). - Query(ADD_NAME_COLUMN) - // Run test - err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), fsys) - // Check error - assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)") - }) - - t.Run("throws error on missing file", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET") - // Run test - err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), fsys) - // Check error - assert.ErrorIs(t, err, os.ErrNotExist) - }) - - t.Run("throws error on missing table", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockMigrationHistory(conn). - Query("RESET ALL"). - Reply("RESET"). - Query(testSchema). - ReplyError(pgerrcode.UndefinedTable, `relation "supabase_migrations.schema_migrations" does not exist`). - Query(INSERT_MIGRATION_VERSION, "0", "schema", []string{testSchema}). - Reply("INSERT 0 1") - // Run test - err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.ErrorContains(t, err, `ERROR: relation "supabase_migrations.schema_migrations" does not exist (SQLSTATE 42P01)`) - }) - - t.Run("throws error when RESET ALL fails", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockMigrationHistory(conn). - Query("RESET ALL"). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for RESET ALL") - // Run test - err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.ErrorContains(t, err, "failed to reset connection state") - assert.ErrorContains(t, err, "ERROR: permission denied for RESET ALL (SQLSTATE 42501)") - }) -} - -func mockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(SET_LOCK_TIMEOUT). - Query(CREATE_VERSION_SCHEMA). - Reply("CREATE SCHEMA"). - Query(CREATE_VERSION_TABLE). - Reply("CREATE TABLE"). - Query(ADD_STATEMENTS_COLUMN). - Reply("ALTER TABLE"). - Query(ADD_NAME_COLUMN). - Reply("ALTER TABLE") - return conn -} diff --git a/apps/cli-go/pkg/migration/drop.go b/apps/cli-go/pkg/migration/drop.go deleted file mode 100644 index aa44407ca1..0000000000 --- a/apps/cli-go/pkg/migration/drop.go +++ /dev/null @@ -1,50 +0,0 @@ -package migration - -import ( - "context" - _ "embed" - - "github.com/go-errors/errors" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/pgxv5" -) - -var ( - //go:embed queries/drop.sql - DropObjects string - //go:embed queries/list.sql - ListSchemas string - - // Initialised by postgres image and owned by postgres role - ManagedSchemas = []string{ - `information\_schema`, - `pg\_%`, - `\_analytics`, - `\_realtime`, - `\_supavisor`, - "pgbouncer", - "pgmq", - "pgsodium", - "pgtle", - `supabase\_migrations`, - "vault", - } -) - -func DropUserSchemas(ctx context.Context, conn *pgx.Conn) error { - migration := MigrationFile{} - migration.Statements = append(migration.Statements, DropObjects) - return migration.ExecBatch(ctx, conn) -} - -func ListUserSchemas(ctx context.Context, conn *pgx.Conn, exclude ...string) ([]string, error) { - if len(exclude) == 0 { - exclude = ManagedSchemas - } - rows, err := conn.Query(ctx, ListSchemas, exclude) - if err != nil { - return nil, errors.Errorf("failed to list schemas: %w", err) - } - // TODO: show detail and hint from pgconn.PgError - return pgxv5.CollectStrings(rows) -} diff --git a/apps/cli-go/pkg/migration/drop_test.go b/apps/cli-go/pkg/migration/drop_test.go deleted file mode 100644 index 644fb69be0..0000000000 --- a/apps/cli-go/pkg/migration/drop_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package migration - -import ( - "context" - "testing" - - "github.com/jackc/pgerrcode" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestDropSchemas(t *testing.T) { - t.Run("resets remote database", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(DropObjects). - Reply("INSERT 0") - // Run test - err := DropUserSchemas(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on drop schema failure", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(DropObjects). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") - // Run test - err := DropUserSchemas(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)") - }) -} diff --git a/apps/cli-go/pkg/migration/dump.go b/apps/cli-go/pkg/migration/dump.go deleted file mode 100644 index 0a1d6cdb3d..0000000000 --- a/apps/cli-go/pkg/migration/dump.go +++ /dev/null @@ -1,221 +0,0 @@ -package migration - -import ( - "context" - _ "embed" - "fmt" - "io" - "strings" - - "github.com/jackc/pgconn" -) - -var ( - //go:embed scripts/dump_schema.sh - dumpSchemaScript string - //go:embed scripts/dump_data.sh - dumpDataScript string - //go:embed scripts/dump_role.sh - dumpRoleScript string - - InternalSchemas = []string{ - "information_schema", - "pg_*", // Wildcard pattern follows pg_dump - // Initialised by supabase/postgres image and owned by postgres role - "_analytics", - "_realtime", - "_supavisor", - "auth", - "etl", - "extensions", - "pgbouncer", - "realtime", - "storage", - "supabase_functions", - "supabase_migrations", - // Owned by extensions - "cron", - "dbdev", - "graphql", - "graphql_public", - "net", - "pgmq", - "pgsodium", - "pgsodium_masks", - "pgtle", - "repack", - "tiger", - "tiger_data", - "timescaledb_*", - "_timescaledb_*", - "topology", - "vault", - } - // Data dump includes auth, storage, etc. for migrating to new project - excludedSchemas = []string{ - "information_schema", - "pg_*", // Wildcard pattern follows pg_dump - // Owned by extensions - // "cron", - "graphql", - "graphql_public", - // "net", - // "pgmq", - "pgsodium", - "pgsodium_masks", - "pgtle", - "repack", - "tiger", - "tiger_data", - "timescaledb_*", - "_timescaledb_*", - "topology", - "vault", - // Managed by Supabase - // "auth", - "etl", - "extensions", - "pgbouncer", - "realtime", - // "storage", - // "supabase_functions", - "supabase_migrations", - // TODO: Remove in a few version in favor of _supabase internal db - "_analytics", - "_realtime", - "_supavisor", - } - reservedRoles = []string{ - "anon", - "authenticated", - "authenticator", - "cli_login_.*", - "dashboard_user", - "pgbouncer", - "postgres", - "service_role", - "supabase_.*", - // Managed by extensions - "pgsodium_keyholder", - "pgsodium_keyiduser", - "pgsodium_keymaker", - "pgtle_admin", - } - allowedConfigs = []string{ - // Ref: https://github.com/supabase/postgres/blob/develop/ansible/files/postgresql_config/supautils.conf.j2#L10 - "pgaudit.*", - "pgrst.*", - "session_replication_role", - "statement_timeout", - "track_io_timing", - } -) - -type pgDumpOption struct { - schema []string - keepComments bool - excludeTable []string - columnInsert bool -} - -type DumpOptionFunc func(*pgDumpOption) - -func WithSchema(schema ...string) DumpOptionFunc { - return func(pdo *pgDumpOption) { - pdo.schema = schema - } -} - -func WithComments(keep bool) DumpOptionFunc { - return func(pdo *pgDumpOption) { - pdo.keepComments = keep - } -} - -func WithColumnInsert(use bool) DumpOptionFunc { - return func(pdo *pgDumpOption) { - pdo.columnInsert = use - } -} - -func WithoutTable(table ...string) DumpOptionFunc { - return func(pdo *pgDumpOption) { - pdo.excludeTable = table - } -} - -func toEnv(config pgconn.Config) []string { - return []string{ - "PGHOST=" + config.Host, - fmt.Sprintf("PGPORT=%d", config.Port), - "PGUSER=" + config.User, - "PGPASSWORD=" + config.Password, - "PGDATABASE=" + config.Database, - } -} - -type ExecFunc func(context.Context, string, []string, io.Writer) error - -func DumpSchema(ctx context.Context, config pgconn.Config, w io.Writer, exec ExecFunc, opts ...DumpOptionFunc) error { - var opt pgDumpOption - for _, apply := range opts { - apply(&opt) - } - env := toEnv(config) - if len(opt.schema) > 0 { - // Must append flag because empty string results in error - env = append(env, "EXTRA_FLAGS=--schema="+strings.Join(opt.schema, "|")) - } else { - env = append(env, "EXCLUDED_SCHEMAS="+strings.Join(InternalSchemas, "|")) - } - if !opt.keepComments { - env = append(env, "EXTRA_SED=/^--/d") - } - return exec(ctx, dumpSchemaScript, env, w) -} - -func DumpData(ctx context.Context, config pgconn.Config, w io.Writer, exec ExecFunc, opts ...DumpOptionFunc) error { - var opt pgDumpOption - for _, apply := range opts { - apply(&opt) - } - env := toEnv(config) - if len(opt.schema) > 0 { - env = append(env, "INCLUDED_SCHEMAS="+strings.Join(opt.schema, "|")) - } else { - env = append(env, "INCLUDED_SCHEMAS=*", "EXCLUDED_SCHEMAS="+strings.Join(excludedSchemas, "|")) - } - var extraFlags []string - if opt.columnInsert { - extraFlags = append(extraFlags, "--column-inserts", "--rows-per-insert 100000") - } - for _, table := range opt.excludeTable { - escaped := quoteUpperCase(table) - // Use separate flags to avoid error: too many dotted names - extraFlags = append(extraFlags, "--exclude-table "+escaped) - } - if len(extraFlags) > 0 { - env = append(env, "EXTRA_FLAGS="+strings.Join(extraFlags, " ")) - } - return exec(ctx, dumpDataScript, env, w) -} - -func quoteUpperCase(table string) string { - escaped := strings.ReplaceAll(table, ".", `"."`) - return fmt.Sprintf(`"%s"`, escaped) -} - -func DumpRole(ctx context.Context, config pgconn.Config, w io.Writer, exec ExecFunc, opts ...DumpOptionFunc) error { - var opt pgDumpOption - for _, apply := range opts { - apply(&opt) - } - env := append(toEnv(config), - "RESERVED_ROLES="+strings.Join(reservedRoles, "|"), - "ALLOWED_CONFIGS="+strings.Join(allowedConfigs, "|"), - ) - if !opt.keepComments { - env = append(env, "EXTRA_SED=/^--/d") - } - return exec(ctx, dumpRoleScript, env, w) -} diff --git a/apps/cli-go/pkg/migration/file.go b/apps/cli-go/pkg/migration/file.go deleted file mode 100644 index e74d322c78..0000000000 --- a/apps/cli-go/pkg/migration/file.go +++ /dev/null @@ -1,289 +0,0 @@ -package migration - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "io/fs" - "path/filepath" - "regexp" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgtype" - "github.com/jackc/pgx/v4" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/parser" -) - -type MigrationFile struct { - Version string - Name string - Statements []string -} - -var ( - migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) - typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) - createIndexPattern = regexp.MustCompile(`^CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY(\s|\z)`) - dropIndexPattern = regexp.MustCompile(`^DROP\s+INDEX\s+CONCURRENTLY(\s|\z)`) - reindexPattern = regexp.MustCompile(`^REINDEX(\s|\().*\sCONCURRENTLY(\s|\z)`) - vacuumPattern = regexp.MustCompile(`^VACUUM(\s|\(|\z)`) - alterSystemPattern = regexp.MustCompile(`^ALTER\s+SYSTEM(\s|\z)`) - clusterPattern = regexp.MustCompile(`^CLUSTER(\s|\z)`) -) - -func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) { - lines, err := parseFile(path, fsys) - if err != nil { - return nil, err - } - file := MigrationFile{Statements: lines} - // Parse version from file name - filename := filepath.Base(path) - matches := migrateFilePattern.FindStringSubmatch(filename) - if len(matches) > 2 { - file.Version = matches[1] - file.Name = matches[2] - } - return &file, nil -} - -func parseFile(path string, fsys fs.FS) ([]string, error) { - sql, err := fsys.Open(path) - if err != nil { - return nil, errors.Errorf("failed to open migration file: %w", err) - } - defer sql.Close() - // Unless explicitly specified, Use file length as max buffer size - if !viper.IsSet("SCANNER_BUFFER_SIZE") { - if fi, err := sql.Stat(); err == nil { - if size := int(fi.Size()); size > parser.MaxScannerCapacity { - parser.MaxScannerCapacity = size - } - } - } - return parser.SplitAndTrim(sql) -} - -func NewMigrationFromReader(sql io.Reader) (*MigrationFile, error) { - lines, err := parser.SplitAndTrim(sql) - if err != nil { - return nil, err - } - return &MigrationFile{Statements: lines}, nil -} - -func isPipelineIncompatible(sql string) bool { - upper := strings.ToUpper(trimLeadingSQLComments(sql)) - return createIndexPattern.MatchString(upper) || - dropIndexPattern.MatchString(upper) || - reindexPattern.MatchString(upper) || - vacuumPattern.MatchString(upper) || - alterSystemPattern.MatchString(upper) || - clusterPattern.MatchString(upper) -} - -func trimLeadingSQLComments(sql string) string { - trimmed := strings.TrimLeftFunc(sql, func(r rune) bool { - return r == '\ufeff' || r == ' ' || r == '\t' || r == '\n' || r == '\r' - }) - for { - switch { - case strings.HasPrefix(trimmed, "--"): - if idx := strings.IndexByte(trimmed, '\n'); idx >= 0 { - trimmed = strings.TrimLeft(trimmed[idx+1:], " \t\n\r") - continue - } - return "" - case strings.HasPrefix(trimmed, "/*"): - if idx := strings.Index(trimmed, "*/"); idx >= 0 { - trimmed = strings.TrimLeft(trimmed[idx+2:], " \t\n\r") - continue - } - return trimmed - default: - return strings.TrimSpace(trimmed) - } - } -} - -// ExecBatch is also reached from the shipped supabase-go sidecar via the -// remaining Go-delegated paths (e.g. remote `db push`/`db reset`'s -// apply.MigrateAndSeed) — local `db start`/`db reset` no longer delegate here -// at all: CLI-1954/CLI-1955 removed the hidden `db __db-bootstrap` seam -// (apps/cli-go/cmd/db.go) this comment used to describe, in favor of a fully -// native TypeScript container-bootstrap port. -func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { - batch := &pgconn.Batch{} - batchSize := 0 - executed := 0 - - formatError := func(err error, i int) error { - stat := INSERT_MIGRATION_VERSION - if i < len(m.Statements) { - stat = m.Statements[i] - } - var msg []string - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - stat = markError(stat, int(pgErr.Position)) - if len(pgErr.Detail) > 0 { - msg = append(msg, pgErr.Detail) - } - if typeName := extractTypeName(pgErr.Message); len(typeName) > 0 && pgErr.Code == "42704" && !IsSchemaQualified(typeName) { - msg = append(msg, "") - msg = append(msg, "Hint: This type may be defined in a schema that's not in your search_path.") - msg = append(msg, " Use schema-qualified type references to avoid this error:") - msg = append(msg, fmt.Sprintf(" CREATE TABLE example (col extensions.%s);", typeName)) - msg = append(msg, " Learn more: supabase migration new --help") - } - } - msg = append(msg, fmt.Sprintf("At statement: %d", i), stat) - return errors.Errorf("%w\n%s", err, strings.Join(msg, "\n")) - } - - flushBatch := func() error { - if batchSize == 0 { - return nil - } - if result, err := conn.PgConn().ExecBatch(ctx, batch).ReadAll(); err != nil { - return formatError(err, executed+len(result)) - } - executed += batchSize - batch = &pgconn.Batch{} - batchSize = 0 - return nil - } - - for _, line := range m.Statements { - if isPipelineIncompatible(line) { - if err := flushBatch(); err != nil { - return err - } - if _, err := conn.PgConn().Exec(ctx, line).ReadAll(); err != nil { - return formatError(err, executed) - } - executed++ - } else { - batch.ExecParams(line, nil, nil, nil, nil) - batchSize++ - } - } - - // Insert into migration history - if len(m.Version) > 0 { - if err := m.insertVersionSQL(conn, batch); err != nil { - return err - } - batchSize++ - } - - return flushBatch() -} - -func markError(stat string, pos int) string { - lines := strings.Split(stat, "\n") - for j, r := range lines { - if c := len(r); pos > c { - pos -= c + 1 - continue - } - // Show a caret below the error position - if pos > 0 { - caret := append(bytes.Repeat([]byte{' '}, pos-1), '^') - lines = append(lines[:j+1], string(caret)) - } - break - } - return strings.Join(lines, "\n") -} - -// extractTypeName extracts the type name from PostgreSQL error messages like: -// 'type "ltree" does not exist' -> "ltree" -func extractTypeName(errMsg string) string { - matches := typeNamePattern.FindStringSubmatch(errMsg) - if len(matches) > 1 { - return matches[1] - } - return "" -} - -// IsSchemaQualified checks if a type name already contains a schema qualifier (e.g., "extensions.ltree") -func IsSchemaQualified(typeName string) bool { - return strings.Contains(typeName, ".") -} - -func (m *MigrationFile) insertVersionSQL(conn *pgx.Conn, batch *pgconn.Batch) error { - value := pgtype.TextArray{} - if err := value.Set(m.Statements); err != nil { - return errors.Errorf("failed to set text array: %w", err) - } - ci := conn.ConnInfo() - var err error - var encoded []byte - var valueFormat int16 - if conn.Config().PreferSimpleProtocol { - encoded, err = value.EncodeText(ci, encoded) - valueFormat = pgtype.TextFormatCode - } else { - encoded, err = value.EncodeBinary(ci, encoded) - valueFormat = pgtype.BinaryFormatCode - } - if err != nil { - return errors.Errorf("failed to encode binary: %w", err) - } - batch.ExecParams( - INSERT_MIGRATION_VERSION, - [][]byte{[]byte(m.Version), []byte(m.Name), encoded}, - []uint32{pgtype.TextOID, pgtype.TextOID, pgtype.TextArrayOID}, - []int16{pgtype.TextFormatCode, pgtype.TextFormatCode, valueFormat}, - nil, - ) - return nil -} - -type SeedFile struct { - Path string - Hash string - Dirty bool `db:"-"` -} - -func NewSeedFile(path string, fsys fs.FS) (*SeedFile, error) { - sql, err := fsys.Open(path) - if err != nil { - return nil, errors.Errorf("failed to open seed file: %w", err) - } - defer sql.Close() - hash := sha256.New() - if _, err := io.Copy(hash, sql); err != nil { - return nil, errors.Errorf("failed to hash file: %w", err) - } - digest := hex.EncodeToString(hash.Sum(nil)) - return &SeedFile{Path: path, Hash: digest}, nil -} - -func (m *SeedFile) ExecBatchWithCache(ctx context.Context, conn *pgx.Conn, fsys fs.FS) error { - // Parse each file individually to reduce memory usage - lines, err := parseFile(m.Path, fsys) - if err != nil { - return err - } - // Data statements don't mutate schemas, safe to use statement cache - batch := pgx.Batch{} - if !m.Dirty { - for _, line := range lines { - batch.Queue(line) - } - } - batch.Queue(UPSERT_SEED_FILE, m.Path, m.Hash) - // No need to track version here because there are no schema changes - if err := conn.SendBatch(ctx, &batch).Close(); err != nil { - return errors.Errorf("failed to send batch: %w", err) - } - return nil -} diff --git a/apps/cli-go/pkg/migration/file_test.go b/apps/cli-go/pkg/migration/file_test.go deleted file mode 100644 index 83087ba0e6..0000000000 --- a/apps/cli-go/pkg/migration/file_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package migration - -import ( - "bufio" - "context" - "strings" - "testing" - fs "testing/fstest" - - "github.com/jackc/pgerrcode" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/pkg/parser" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestMigrationFile(t *testing.T) { - t.Run("new from file sets max token", func(t *testing.T) { - // Setup in-memory fs - path := "20220727064247_create_table.sql" - query := "BEGIN; " + strings.Repeat("a", parser.MaxScannerCapacity) - fsys := fs.MapFS{ - path: &fs.MapFile{Data: []byte(query)}, - } - // Run test - migration, err := NewMigrationFromFile(path, fsys) - // Check error - assert.NoError(t, err) - assert.Len(t, migration.Statements, 2) - assert.Equal(t, "20220727064247", migration.Version) - }) - - t.Run("new from reader errors on max token", func(t *testing.T) { - viper.Reset() - sql := "\tBEGIN; " + strings.Repeat("a", parser.MaxScannerCapacity) - // Run test - migration, err := NewMigrationFromReader(strings.NewReader(sql)) - // Check error - assert.ErrorIs(t, err, bufio.ErrTooLong) - assert.ErrorContains(t, err, "After statement 1: \tBEGIN;") - assert.Nil(t, migration) - }) - - t.Run("encodes statements in binary format", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{"create schema public"}, - Version: "0", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - Reply("CREATE SCHEMA"). - Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - }) - - t.Run("executes pipeline incompatible statements outside batch", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{ - "create table public.widgets(id bigint primary key)", - "CREATE UNIQUE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", - "alter table public.widgets enable row level security", - }, - Version: "20260101000000", - Name: "create_widgets", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - Reply("CREATE TABLE"). - SimpleQuery(migration.Statements[1]). - Reply("CREATE INDEX"). - Query(migration.Statements[2]). - Reply("ALTER TABLE"). - Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - }) - - t.Run("records migration version when file has no statements", func(t *testing.T) { - migration := MigrationFile{ - Version: "20260101000000", - Name: "empty_migration", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - }) - - t.Run("reports pipeline incompatible statement errors with statement index", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{ - "create table public.widgets(id bigint primary key)", - "CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", - "alter table public.widgets enable row level security", - }, - Version: "20260101000000", - Name: "create_widgets", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - Reply("CREATE TABLE"). - SimpleQuery(migration.Statements[1]). - ReplyError("25001", "CREATE INDEX CONCURRENTLY cannot be executed within a pipeline") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, "ERROR: CREATE INDEX CONCURRENTLY cannot be executed within a pipeline (SQLSTATE 25001)") - assert.ErrorContains(t, err, "At statement: 1\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)") - }) - - t.Run("throws error on insert failure", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{"create schema public"}, - Version: "0", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). - Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, "ERROR: schema \"public\" already exists (SQLSTATE 42P06)") - assert.ErrorContains(t, err, "At statement: 0\ncreate schema public") - }) - - t.Run("provides helpful hint for extension type errors", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{"CREATE TABLE test (path ltree NOT NULL)"}, - Version: "0", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - ReplyError("42704", `type "ltree" does not exist`). - Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, `type "ltree" does not exist`) - assert.ErrorContains(t, err, "Hint: This type may be defined in a schema") - assert.ErrorContains(t, err, "extensions.ltree") - assert.ErrorContains(t, err, "supabase migration new --help") - assert.ErrorContains(t, err, "At statement: 0") - }) - - t.Run("skips hint for schema-qualified type errors", func(t *testing.T) { - migration := MigrationFile{ - Statements: []string{"CREATE TABLE test (path extensions.ltree NOT NULL)"}, - Version: "0", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.Statements[0]). - ReplyError("42704", `type "extensions.ltree" does not exist`). - Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") - // Run test - err := migration.ExecBatch(context.Background(), conn.MockClient(t)) - // Check error - should NOT contain hint since type is already schema-qualified - assert.ErrorContains(t, err, `type "extensions.ltree" does not exist`) - assert.NotContains(t, err.Error(), "Hint: This type may be defined in a schema") - }) -} - -func TestExtractTypeName(t *testing.T) { - t.Run("extracts type name from standard error message", func(t *testing.T) { - result := extractTypeName(`type "ltree" does not exist`) - assert.Equal(t, "ltree", result) - }) - - t.Run("extracts schema-qualified type name", func(t *testing.T) { - result := extractTypeName(`type "extensions.ltree" does not exist`) - assert.Equal(t, "extensions.ltree", result) - }) - - t.Run("extracts type with underscores", func(t *testing.T) { - result := extractTypeName(`type "my_custom_type" does not exist`) - assert.Equal(t, "my_custom_type", result) - }) - - t.Run("returns empty string for non-matching message", func(t *testing.T) { - result := extractTypeName(`column "name" does not exist`) - assert.Equal(t, "", result) - }) - - t.Run("returns empty string for empty message", func(t *testing.T) { - result := extractTypeName("") - assert.Equal(t, "", result) - }) - - t.Run("handles type names with numbers", func(t *testing.T) { - result := extractTypeName(`type "type123" does not exist`) - assert.Equal(t, "type123", result) - }) -} - -func TestIsPipelineIncompatible(t *testing.T) { - cases := []struct { - name string - sql string - want bool - }{ - { - name: "create index concurrently", - sql: "CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", - want: true, - }, - { - name: "create unique index concurrently", - sql: "CREATE UNIQUE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", - want: true, - }, - { - name: "create index concurrently after comments", - sql: "-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)", - want: true, - }, - { - name: "drop index concurrently", - sql: "DROP INDEX CONCURRENTLY public.widgets_id_idx", - want: true, - }, - { - name: "drop index concurrently if exists", - sql: "drop index concurrently if exists api.idx_rx_orders_clinic_id", - want: true, - }, - { - name: "reindex table concurrently", - sql: "REINDEX TABLE CONCURRENTLY public.widgets", - want: true, - }, - { - name: "reindex with options concurrently", - sql: "REINDEX (VERBOSE) INDEX CONCURRENTLY widgets_id_idx", - want: true, - }, - { - name: "vacuum bare", - sql: "VACUUM", - want: true, - }, - { - name: "vacuum with options", - sql: "VACUUM (ANALYZE) public.widgets", - want: true, - }, - { - name: "alter system", - sql: "ALTER SYSTEM SET log_statement = 'all'", - want: true, - }, - { - name: "cluster", - sql: "CLUSTER public.widgets USING widgets_id_idx", - want: true, - }, - { - name: "ordinary create index", - sql: "CREATE INDEX widgets_id_idx ON public.widgets(id)", - want: false, - }, - { - name: "ordinary drop index", - sql: "DROP INDEX IF EXISTS public.widgets_id_idx", - want: false, - }, - { - name: "concurrently in string literal", - sql: "SELECT 'CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)'", - want: false, - }, - { - name: "concurrently in leading comment only", - sql: "-- CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)\nSELECT 1", - want: false, - }, - { - name: "word prefix", - sql: "VACUUMING public.widgets", - want: false, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isPipelineIncompatible(tt.sql)) - }) - } -} - -func TestIsSchemaQualified(t *testing.T) { - assert.True(t, IsSchemaQualified("extensions.ltree")) - assert.True(t, IsSchemaQualified("public.my_type")) - assert.False(t, IsSchemaQualified("ltree")) - assert.False(t, IsSchemaQualified("")) -} diff --git a/apps/cli-go/pkg/migration/history.go b/apps/cli-go/pkg/migration/history.go deleted file mode 100644 index 9f156faa4a..0000000000 --- a/apps/cli-go/pkg/migration/history.go +++ /dev/null @@ -1,73 +0,0 @@ -package migration - -import ( - "context" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/pgxv5" -) - -const ( - SET_LOCK_TIMEOUT = "SET lock_timeout = '4s'" - CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations" - CREATE_VERSION_TABLE = "CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY)" - ADD_STATEMENTS_COLUMN = "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]" - ADD_NAME_COLUMN = "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text" - INSERT_MIGRATION_VERSION = "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)" - UPSERT_MIGRATION_VERSION = INSERT_MIGRATION_VERSION + " ON CONFLICT (version) DO UPDATE SET name = EXCLUDED.name, statements = EXCLUDED.statements" - DELETE_MIGRATION_VERSION = "DELETE FROM supabase_migrations.schema_migrations WHERE version = ANY($1)" - DELETE_MIGRATION_BEFORE = "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1" - TRUNCATE_VERSION_TABLE = "TRUNCATE supabase_migrations.schema_migrations" - SELECT_VERSION_TABLE = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations" - LIST_MIGRATION_VERSION = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version" - CREATE_SEED_TABLE = "CREATE TABLE IF NOT EXISTS supabase_migrations.seed_files (path text NOT NULL PRIMARY KEY, hash text NOT NULL)" - UPSERT_SEED_FILE = "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash" - SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files" -) - -// TODO: support overriding `supabase_migrations.schema_migrations` with user defined . -func CreateMigrationTable(ctx context.Context, conn *pgx.Conn) error { - // This must be run without prepared statements because each statement in the batch depends on - // the previous schema change. The lock timeout will be reset when implicit transaction ends. - batch := pgconn.Batch{} - batch.ExecParams(SET_LOCK_TIMEOUT, nil, nil, nil, nil) - batch.ExecParams(CREATE_VERSION_SCHEMA, nil, nil, nil, nil) - batch.ExecParams(CREATE_VERSION_TABLE, nil, nil, nil, nil) - batch.ExecParams(ADD_STATEMENTS_COLUMN, nil, nil, nil, nil) - batch.ExecParams(ADD_NAME_COLUMN, nil, nil, nil, nil) - if _, err := conn.PgConn().ExecBatch(ctx, &batch).ReadAll(); err != nil { - return errors.Errorf("failed to create migration table: %w", err) - } - return nil -} - -func ReadMigrationTable(ctx context.Context, conn *pgx.Conn) ([]MigrationFile, error) { - rows, err := conn.Query(ctx, SELECT_VERSION_TABLE) - if err != nil { - return nil, errors.Errorf("failed to read migration table: %w", err) - } - return pgxv5.CollectRows[MigrationFile](rows) -} - -func CreateSeedTable(ctx context.Context, conn *pgx.Conn) error { - // This must be run without prepared statements because each statement in the batch depends on - // the previous schema change. The lock timeout will be reset when implicit transaction ends. - batch := pgconn.Batch{} - batch.ExecParams(SET_LOCK_TIMEOUT, nil, nil, nil, nil) - batch.ExecParams(CREATE_VERSION_SCHEMA, nil, nil, nil, nil) - batch.ExecParams(CREATE_SEED_TABLE, nil, nil, nil, nil) - if _, err := conn.PgConn().ExecBatch(ctx, &batch).ReadAll(); err != nil { - return errors.Errorf("failed to create seed table: %w", err) - } - return nil -} - -func ReadSeedTable(ctx context.Context, conn *pgx.Conn) ([]SeedFile, error) { - rows, err := conn.Query(ctx, SELECT_SEED_TABLE) - if err != nil { - return nil, errors.Errorf("failed to read seed table: %w", err) - } - return pgxv5.CollectRows[SeedFile](rows) -} diff --git a/apps/cli-go/pkg/migration/list.go b/apps/cli-go/pkg/migration/list.go deleted file mode 100644 index 8972bb2594..0000000000 --- a/apps/cli-go/pkg/migration/list.go +++ /dev/null @@ -1,77 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "strconv" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/pgxv5" -) - -func ListRemoteMigrations(ctx context.Context, conn *pgx.Conn) ([]string, error) { - // We query the version string only for backwards compatibility - rows, _ := conn.Query(ctx, LIST_MIGRATION_VERSION) - versions, err := pgxv5.CollectStrings(rows) - if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UndefinedTable { - // If migration history table is undefined, the remote project has no migrations - return nil, nil - } - } - return versions, err -} - -func ListLocalMigrations(migrationsDir string, fsys fs.FS, filter ...func(string) bool) ([]string, error) { - localMigrations, err := fs.ReadDir(fsys, migrationsDir) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, errors.Errorf("failed to read directory: %w", err) - } - var clean []string -OUTER: - for i, migration := range localMigrations { - if migration.IsDir() { - continue - } - filename := migration.Name() - if i == 0 && shouldSkip(filename) { - fmt.Fprintf(os.Stderr, "Skipping migration %s... (replace \"init\" with a different file name to apply this migration)\n", filename) - continue - } - matches := migrateFilePattern.FindStringSubmatch(filename) - if len(matches) == 0 { - fmt.Fprintf(os.Stderr, "Skipping migration %s... (file name must match pattern \"_name.sql\")\n", filename) - continue - } - path := filepath.Join(migrationsDir, filename) - for _, keep := range filter { - if version := matches[1]; !keep(version) { - continue OUTER - } - } - clean = append(clean, path) - } - return clean, nil -} - -var initSchemaPattern = regexp.MustCompile(`([0-9]{14})_init\.sql`) - -func shouldSkip(name string) bool { - // NOTE: To handle backward-compatibility. `_init.sql` as - // the first migration (prev versions of the CLI) is deprecated. - matches := initSchemaPattern.FindStringSubmatch(name) - if len(matches) == 2 { - if timestamp, err := strconv.ParseUint(matches[1], 10, 64); err == nil && timestamp < 20211209000000 { - return true - } - } - return false -} diff --git a/apps/cli-go/pkg/migration/list_test.go b/apps/cli-go/pkg/migration/list_test.go deleted file mode 100644 index 4654fa876a..0000000000 --- a/apps/cli-go/pkg/migration/list_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package migration - -import ( - "context" - "testing" - fs "testing/fstest" - - "github.com/jackc/pgerrcode" - "github.com/stretchr/testify/assert" - "github.com/supabase/cli/pkg/pgtest" -) - -func TestRemoteMigrations(t *testing.T) { - t.Run("loads migration versions", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{"20220727064247"}) - // Run test - versions, err := ListRemoteMigrations(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, []string{"20220727064247"}, versions) - }) - - t.Run("loads empty migrations on missing table", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(LIST_MIGRATION_VERSION). - ReplyError(pgerrcode.UndefinedTable, "relation \"supabase_migrations.schema_migrations\" does not exist") - // Run test - versions, err := ListRemoteMigrations(context.Background(), conn.MockClient(t)) - // Check error - assert.NoError(t, err) - assert.Empty(t, versions) - }) - - t.Run("throws error on invalid row", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{}) - // Run test - _, err := ListRemoteMigrations(context.Background(), conn.MockClient(t)) - // Check error - assert.ErrorContains(t, err, "number of field descriptions must equal number of destinations, got 0 and 1") - }) -} - -func TestLocalMigrations(t *testing.T) { - t.Run("loads migration versions", func(t *testing.T) { - // Setup in-memory fs - files := []string{ - "20220727064246_test.sql", - "20220727064248_test.sql", - } - fsys := fs.MapFS{} - for _, name := range files { - fsys[name] = &fs.MapFile{} - } - // Run test - versions, err := ListLocalMigrations(".", fsys) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, files, versions) - }) - - t.Run("ignores outdated and invalid files", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{ - "20211208000000_init.sql": &fs.MapFile{}, - "20211208000001_invalid.ts": &fs.MapFile{}, - } - // Run test - versions, err := ListLocalMigrations(".", fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, versions) - }) - - t.Run("throws error on open failure", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{"migrations": &fs.MapFile{}} - // Run test - _, err := ListLocalMigrations("migrations", fsys) - // Check error - assert.ErrorContains(t, err, "failed to read directory:") - }) -} diff --git a/apps/cli-go/pkg/migration/queries/drop.sql b/apps/cli-go/pkg/migration/queries/drop.sql deleted file mode 100644 index 568e15eef0..0000000000 --- a/apps/cli-go/pkg/migration/queries/drop.sql +++ /dev/null @@ -1,136 +0,0 @@ -do $$ declare - rec record; -begin - -- schemas - for rec in - select pn.* - from pg_catalog.pg_namespace pn - left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass - where pd.deptype is null - and not pn.nspname like any(array['information\_schema', 'pg\_%', '\_analytics', '\_realtime', '\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\_migrations', 'vault', 'extensions', 'public']) - and pn.nspowner::regrole::text != 'supabase_admin' - loop - -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also - -- drop the extension. But if an extension creates its own schema, dropping the schema will - -- throw an error. Hence, we drop schemas first while excluding those created by extensions. - raise notice 'dropping schema: %', rec.nspname; - execute format('drop schema if exists %I cascade', rec.nspname); - end loop; - - -- extensions - for rec in - select * - from pg_extension p - where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') - loop - raise notice 'dropping extension: %', rec.extname; - execute format('drop extension if exists %I cascade', rec.extname); - end loop; - - -- functions - for rec in - select * - from pg_proc p - where p.pronamespace::regnamespace::name = 'public' - loop - -- supports aggregate, function, and procedure - raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; - execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); - end loop; - - -- views (necessary for views referencing objects in Supabase-managed schemas) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 'v' - loop - raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 'm' - loop - raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- tables (cascade to dependent objects) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind not in ('c', 'S', 'v', 'm') - order by c.relkind desc - loop - -- supports all table like relations, except views, complex types, and sequences - raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- truncate tables in auth, webhooks, and migrations schema - for rec in - select * - from pg_class c - where - (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' - or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' - or c.relnamespace::regnamespace::name = 'supabase_migrations') - and c.relkind = 'r' - loop - raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- sequences - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 's' - loop - raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- types - for rec in - select * - from pg_type t - where - t.typnamespace::regnamespace::name = 'public' - and typtype != 'b' - loop - raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; - execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); - end loop; - - -- policies - for rec in - select * - from pg_policies p - loop - raise notice 'dropping policy: %', rec.policyname; - execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); - end loop; - - -- publications - for rec in - select * - from pg_publication p - where - not p.pubname like any(array['supabase\_realtime%', 'realtime\_messages%']) - loop - raise notice 'dropping publication: %', rec.pubname; - execute format('drop publication if exists %I', rec.pubname); - end loop; -end $$; diff --git a/apps/cli-go/pkg/migration/queries/list.sql b/apps/cli-go/pkg/migration/queries/list.sql deleted file mode 100644 index 7ab6201174..0000000000 --- a/apps/cli-go/pkg/migration/queries/list.sql +++ /dev/null @@ -1,10 +0,0 @@ --- List user defined schemas, excluding --- Extension created schemas --- Supabase managed schemas -select pn.nspname -from pg_catalog.pg_namespace pn -left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass -where pd.deptype is null - and not pn.nspname like any($1) - and pn.nspowner::regrole::text != 'supabase_admin' -order by pn.nspname diff --git a/apps/cli-go/pkg/migration/scripts/dump_data.sh b/apps/cli-go/pkg/migration/scripts/dump_data.sh deleted file mode 100755 index fed47b54e1..0000000000 --- a/apps/cli-go/pkg/migration/scripts/dump_data.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PGHOST="$PGHOST" -export PGPORT="$PGPORT" -export PGUSER="$PGUSER" -export PGPASSWORD="$PGPASSWORD" -export PGDATABASE="$PGDATABASE" - -# Disable triggers so that data dump can be restored exactly as it is -echo "SET session_replication_role = replica; -" - -# Explanation of pg_dump flags: -# -# --exclude-schema omit data from internal schemas as they are maintained by platform -# --exclude-table omit data from migration history tables as they are managed by platform -# --column-inserts only column insert syntax is supported, ie. no copy from stdin -# --schema '*' include all other schemas by default -# -# Explanation of sed substitutions: -# -# - do not emit psql meta commands -# -# Never delete SQL comments because multiline records may begin with them. -pg_dump \ - --data-only \ - --quote-all-identifier \ - --role "postgres" \ - --exclude-schema "${EXCLUDED_SCHEMAS:-}" \ - --exclude-table "auth.schema_migrations" \ - --exclude-table "storage.migrations" \ - --exclude-table "supabase_functions.migrations" \ - --schema "$INCLUDED_SCHEMAS" \ - ${EXTRA_FLAGS:-} \ -| sed -E 's/^\\(un)?restrict .*$/-- &/' - -# Reset session config generated by pg_dump -echo "RESET ALL;" diff --git a/apps/cli-go/pkg/migration/scripts/dump_role.sh b/apps/cli-go/pkg/migration/scripts/dump_role.sh deleted file mode 100755 index 96fe6a3068..0000000000 --- a/apps/cli-go/pkg/migration/scripts/dump_role.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PGHOST="$PGHOST" -export PGPORT="$PGPORT" -export PGUSER="$PGUSER" -export PGPASSWORD="$PGPASSWORD" -export PGDATABASE="$PGDATABASE" - -# Explanation of pg_dumpall flags: -# -# --roles-only only include create, alter, and grant role statements -# -# Explanation of sed substitutions: -# -# - do not emit psql meta commands -# - do not create or alter reserved roles as they are blocked by supautils -# - explicitly allow altering safe attributes, ie. statement_timeout, pgrst.* -# - discard role attributes that require superuser, ie. nosuperuser, noreplication -# - do not alter membership grants by supabase_admin role -pg_dumpall \ - --roles-only \ - --role "postgres" \ - --quote-all-identifier \ - --no-role-passwords \ - --no-comments \ -| sed -E 's/^\\(un)?restrict .*$/-- &/' \ -| sed -E "s/^CREATE ROLE \"($RESERVED_ROLES)\"/-- &/" \ -| sed -E "s/^ALTER ROLE \"($RESERVED_ROLES)\"/-- &/" \ -| sed -E "s/ (NOSUPERUSER|NOREPLICATION)//g" \ -| sed -E "s/^-- (.* SET \"($ALLOWED_CONFIGS)\" .*)/\1/" \ -| sed -E "s/GRANT \".*\" TO \"($RESERVED_ROLES)\"/-- &/" \ -| sed -E "${EXTRA_SED:-}" \ -| uniq - -# Reset session config generated by pg_dump -echo "RESET ALL;" diff --git a/apps/cli-go/pkg/migration/scripts/dump_schema.sh b/apps/cli-go/pkg/migration/scripts/dump_schema.sh deleted file mode 100755 index c136cc086e..0000000000 --- a/apps/cli-go/pkg/migration/scripts/dump_schema.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PGHOST="$PGHOST" -export PGPORT="$PGPORT" -export PGUSER="$PGUSER" -export PGPASSWORD="$PGPASSWORD" -export PGDATABASE="$PGDATABASE" - -# Explanation of pg_dump flags: -# -# --schema-only omit data like migration history, pgsodium key, etc. -# --exclude-schema omit internal schemas as they are maintained by platform -# -# Explanation of sed substitutions: -# -# - do not emit psql meta commands -# - do not alter superuser role "supabase_admin" -# - do not alter foreign data wrappers owner -# - do not include ACL changes on internal schemas -# - do not include RLS policies on cron extension schema -# - do not include event triggers -# - do not create pgtle schema and extension comments -# - do not create publication "supabase_realtime" -# - do not set transaction_timeout which requires pg17 -pg_dump \ - --schema-only \ - --quote-all-identifier \ - --role "postgres" \ - --exclude-schema "${EXCLUDED_SCHEMAS:-}" \ - ${EXTRA_FLAGS:-} \ -| sed -E 's/^\\(un)?restrict .*$/-- &/' \ -| sed -E 's/^CREATE SCHEMA "/CREATE SCHEMA IF NOT EXISTS "/' \ -| sed -E 's/^CREATE TABLE "/CREATE TABLE IF NOT EXISTS "/' \ -| sed -E 's/^CREATE SEQUENCE "/CREATE SEQUENCE IF NOT EXISTS "/' \ -| sed -E 's/^CREATE VIEW "/CREATE OR REPLACE VIEW "/' \ -| sed -E 's/^CREATE FUNCTION "/CREATE OR REPLACE FUNCTION "/' \ -| sed -E 's/^CREATE TRIGGER "/CREATE OR REPLACE TRIGGER "/' \ -| sed -E 's/^CREATE PUBLICATION "supabase_realtime/-- &/' \ -| sed -E 's/^CREATE EVENT TRIGGER /-- &/' \ -| sed -E 's/^ WHEN TAG IN /-- &/' \ -| sed -E 's/^ EXECUTE FUNCTION /-- &/' \ -| sed -E 's/^ALTER EVENT TRIGGER /-- &/' \ -| sed -E 's/^ALTER PUBLICATION "supabase_realtime_/-- &/' \ -| sed -E 's/^ALTER FOREIGN DATA WRAPPER (.+) OWNER TO /-- &/' \ -| sed -E 's/^ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin"/-- &/' \ -| sed -E 's/^GRANT ALL ON FOREIGN DATA WRAPPER (.+) TO "postgres" WITH GRANT OPTION/-- &/' \ -| sed -E "s/^GRANT (.+) ON (.+) \"(${EXCLUDED_SCHEMAS:-})\"/-- &/" \ -| sed -E "s/^REVOKE (.+) ON (.+) \"(${EXCLUDED_SCHEMAS:-})\"/-- &/" \ -| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pg_tle").+/\1;/' \ -| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgsodium").+/\1;/' \ -| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgmq").+/\1;/' \ -| sed -E 's/^COMMENT ON EXTENSION (.+)/-- &/' \ -| sed -E 's/^CREATE POLICY "cron_job_/-- &/' \ -| sed -E 's/^ALTER TABLE "cron"/-- &/' \ -| sed -E 's/^SET transaction_timeout = 0;/-- &/' \ -| sed -E "${EXTRA_SED:-}" diff --git a/apps/cli-go/pkg/migration/seed.go b/apps/cli-go/pkg/migration/seed.go deleted file mode 100644 index 2e8fff71c8..0000000000 --- a/apps/cli-go/pkg/migration/seed.go +++ /dev/null @@ -1,100 +0,0 @@ -package migration - -import ( - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/config" -) - -func getRemoteSeeds(ctx context.Context, conn *pgx.Conn) (map[string]string, error) { - remotes, err := ReadSeedTable(ctx, conn) - if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UndefinedTable { - // If seed table is undefined, the remote project has no migrations - return nil, nil - } - return nil, err - } - applied := make(map[string]string, len(remotes)) - for _, seed := range remotes { - applied[seed.Path] = seed.Hash - } - return applied, nil -} - -func GetPendingSeeds(ctx context.Context, locals config.Glob, conn *pgx.Conn, fsys fs.FS) ([]SeedFile, error) { - locals, err := locals.SQLFiles(fsys) - if err != nil { - fmt.Fprintln(os.Stderr, "WARN:", err) - } - if len(locals) == 0 { - return nil, nil - } - applied, err := getRemoteSeeds(ctx, conn) - if err != nil { - return nil, err - } - var pending []SeedFile - for _, path := range locals { - seed, err := NewSeedFile(path, fsys) - if err != nil { - return nil, err - } - if hash, exists := applied[seed.Path]; exists { - // Skip seed files that already exist - if hash == seed.Hash { - continue - } - // Mark seed file as dirty - seed.Dirty = true - } - pending = append(pending, *seed) - } - return pending, nil -} - -func SeedData(ctx context.Context, pending []SeedFile, conn *pgx.Conn, fsys fs.FS) error { - if len(pending) > 0 { - if err := CreateSeedTable(ctx, conn); err != nil { - return err - } - } - for _, seed := range pending { - if seed.Dirty { - fmt.Fprintf(os.Stderr, "Updating seed hash to %s...\n", seed.Path) - } else { - fmt.Fprintf(os.Stderr, "Seeding data from %s...\n", seed.Path) - } - // Batch seed commands, safe to use statement cache - if err := seed.ExecBatchWithCache(ctx, conn, fsys); err != nil { - return err - } - } - return nil -} - -func SeedGlobals(ctx context.Context, pending []string, conn *pgx.Conn, fsys fs.FS) error { - for _, path := range pending { - filename := filepath.Base(path) - fmt.Fprintf(os.Stderr, "Seeding globals from %s...\n", filename) - globals, err := NewMigrationFromFile(path, fsys) - if err != nil { - return err - } - // Skip inserting to migration history - globals.Version = "" - if err := globals.ExecBatch(ctx, conn); err != nil { - return err - } - } - return nil -} diff --git a/apps/cli-go/pkg/migration/seed_test.go b/apps/cli-go/pkg/migration/seed_test.go deleted file mode 100644 index db4337b54c..0000000000 --- a/apps/cli-go/pkg/migration/seed_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package migration - -import ( - "context" - _ "embed" - "os" - "testing" - fs "testing/fstest" - - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/pgtest" -) - -//go:embed testdata/seed.sql -var testSeed string - -func TestPendingSeeds(t *testing.T) { - pending := []string{"testdata/seed.sql"} - - t.Run("finds new seeds", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(SELECT_SEED_TABLE). - Reply("SELECT 0") - // Run test - seeds, err := GetPendingSeeds(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - require.Len(t, seeds, 1) - assert.Equal(t, seeds[0].Path, pending[0]) - assert.Equal(t, seeds[0].Hash, "61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3") - assert.False(t, seeds[0].Dirty) - }) - - t.Run("finds dirty seeds", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(SELECT_SEED_TABLE). - Reply("SELECT 1", SeedFile{Path: pending[0], Hash: "outdated"}) - // Run test - seeds, err := GetPendingSeeds(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - require.Len(t, seeds, 1) - assert.Equal(t, seeds[0].Path, pending[0]) - assert.Equal(t, seeds[0].Hash, "61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3") - assert.True(t, seeds[0].Dirty) - }) - - t.Run("skips applied seed", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(SELECT_SEED_TABLE). - Reply("SELECT 1", SeedFile{Path: pending[0], Hash: "61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3"}) - // Run test - seeds, err := GetPendingSeeds(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - require.Empty(t, seeds) - }) - - t.Run("ignores missing seed table", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(SELECT_SEED_TABLE). - ReplyError(pgerrcode.UndefinedTable, `relation "seed_files" does not exist`) - // Run test - _, err := GetPendingSeeds(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - }) - - t.Run("ignores missing seed file", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Run test - _, err := GetPendingSeeds(context.Background(), pending, nil, fsys) - // Check error - assert.NoError(t, err) - }) -} - -func TestSeedData(t *testing.T) { - t.Run("seeds from file", func(t *testing.T) { - seed := SeedFile{ - Path: "testdata/seed.sql", - Hash: "61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3", - Dirty: true, - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockSeedHistory(conn). - Query(UPSERT_SEED_FILE, seed.Path, seed.Hash). - Reply("INSERT 0 1") - // Run test - err := SeedData(context.Background(), []SeedFile{seed}, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on upsert failure", func(t *testing.T) { - seed := SeedFile{ - Path: "testdata/seed.sql", - Hash: "61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3", - } - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - mockSeedHistory(conn). - Query(testSeed+`;INSERT INTO supabase_migrations.seed_files(path, hash) VALUES( 'testdata/seed.sql' , '61868484fc0ddca2a2022217629a9fd9a4cf1ca479432046290797d6d40ffcc3' ) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash`). - ReplyError(pgerrcode.NotNullViolation, `null value in column "age" of relation "employees"`) - // Run test - err := SeedData(context.Background(), []SeedFile{seed}, conn.MockClient(t, func(cc *pgx.ConnConfig) { - cc.PreferSimpleProtocol = true - }), testMigrations) - // Check error - assert.ErrorContains(t, err, `ERROR: null value in column "age" of relation "employees" (SQLSTATE 23502)`) - }) -} - -func mockSeedHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(SET_LOCK_TIMEOUT). - Query(CREATE_VERSION_SCHEMA). - Reply("CREATE SCHEMA"). - Query(CREATE_SEED_TABLE). - Reply("CREATE TABLE") - return conn -} - -//go:embed testdata/1_globals.sql -var testGlobals string - -func TestSeedGlobals(t *testing.T) { - pending := []string{"testdata/1_globals.sql"} - - t.Run("seeds from file", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(testGlobals). - Reply("CREATE ROLE") - // Run test - err := SeedGlobals(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.NoError(t, err) - }) - - t.Run("throws error on missing file", func(t *testing.T) { - // Setup in-memory fs - fsys := fs.MapFS{} - // Run test - err := SeedGlobals(context.Background(), pending, nil, fsys) - // Check error - assert.ErrorIs(t, err, os.ErrNotExist) - }) - - t.Run("throws error on create failure", func(t *testing.T) { - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(testGlobals). - ReplyError(pgerrcode.InvalidCatalogName, `database "postgres" does not exist`) - // Run test - err := SeedGlobals(context.Background(), pending, conn.MockClient(t), testMigrations) - // Check error - assert.ErrorContains(t, err, `ERROR: database "postgres" does not exist (SQLSTATE 3D000)`) - }) -} diff --git a/apps/cli-go/pkg/migration/testdata/0_schema.sql b/apps/cli-go/pkg/migration/testdata/0_schema.sql deleted file mode 100644 index ba3138abd8..0000000000 --- a/apps/cli-go/pkg/migration/testdata/0_schema.sql +++ /dev/null @@ -1 +0,0 @@ -create schema private \ No newline at end of file diff --git a/apps/cli-go/pkg/migration/testdata/1_globals.sql b/apps/cli-go/pkg/migration/testdata/1_globals.sql deleted file mode 100644 index 0e17ccd4d4..0000000000 --- a/apps/cli-go/pkg/migration/testdata/1_globals.sql +++ /dev/null @@ -1 +0,0 @@ -create role developer_role with login password 'developer' \ No newline at end of file diff --git a/apps/cli-go/pkg/migration/testdata/seed.sql b/apps/cli-go/pkg/migration/testdata/seed.sql deleted file mode 100644 index e48c726ce3..0000000000 --- a/apps/cli-go/pkg/migration/testdata/seed.sql +++ /dev/null @@ -1 +0,0 @@ -INSERT INTO employees(name) VALUES ('Alice') \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/state.go b/apps/cli-go/pkg/parser/state.go deleted file mode 100644 index 0a4bce6662..0000000000 --- a/apps/cli-go/pkg/parser/state.go +++ /dev/null @@ -1,296 +0,0 @@ -package parser - -import ( - "bytes" - "strings" - "unicode" - "unicode/utf8" -) - -const ( - // Omit BEGIN to allow arbitrary whitespaces between BEGIN and ATOMIC keywords. - // This can fail if ATOMIC is used as column name because it is not a reserved - // keyword: https://www.postgresql.org/docs/current/sql-keywords-appendix.html - BEGIN_ATOMIC = "ATOMIC" - END_ATOMIC = "END" -) - -type State interface { - // Return nil to emit token - Next(r rune, data []byte) State -} - -// Initial state: ready to parse next token -type ReadyState struct{} - -func (s *ReadyState) Next(r rune, data []byte) State { - switch r { - case '$': - // A $ after an identifier rune continues the identifier (pending$$foo$), not a - // dollar quote. A digit counts too (1$$), unlike PostgreSQL; valid SQL never has that. - offset := len(data) - utf8.RuneLen(r) - if hasIdentifierRuneBefore(data, offset) { - return s - } - return &TagState{offset: offset} - case '\'': - fallthrough - case '"': - return &QuoteState{delimiter: r} - case '-': - return &CommentState{} - case '/': - return &BlockState{} - case '\\': - return &EscapeState{} - case ';': - // Emit token - return nil - case '(': - return &ParenState{prev: s} - case 'c': - fallthrough - case 'C': - if isBeginAtomic(data) { - return &AtomicState{prev: s, statementStart: len(data)} - } - } - return s -} - -func isBeginAtomic(data []byte) bool { - if !endsWithKeyword(data, BEGIN_ATOMIC) { - return false - } - prefix := bytes.TrimRight(data[:len(data)-len(BEGIN_ATOMIC)], sqlWhitespace) - return endsWithKeyword(prefix, "BEGIN") -} - -// PostgreSQL's scan.l treats every byte at or above 0x80 as an identifier/dollar-tag -// character (ident_cont/dolq_cont), whatever its Unicode category. -func isIdentifierRune(r rune) bool { - return r >= utf8.RuneSelf || unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '$' -} - -func hasIdentifierRuneBefore(data []byte, offset int) bool { - if offset <= 0 { - return false - } - r, _ := utf8.DecodeLastRune(data[:offset]) - return isIdentifierRune(r) -} - -func endsWithKeyword(data []byte, keyword string) bool { - offset := len(data) - len(keyword) - if offset < 0 || !strings.EqualFold(string(data[offset:]), keyword) { - return false - } - return !hasIdentifierRuneBefore(data, offset) -} - -const sqlWhitespace = " \t\n\r\f\v" - -func isSqlWhitespace(b byte) bool { - return bytes.IndexByte([]byte(sqlWhitespace), b) >= 0 -} - -func isCommentsAndWhitespace(text []byte) bool { - for i := 0; i < len(text); { - switch { - case isSqlWhitespace(text[i]): - i++ - case bytes.HasPrefix(text[i:], []byte("--")): - newline := bytes.IndexByte(text[i+2:], '\n') - if newline == -1 { - return true - } - i += 2 + newline + 1 - case bytes.HasPrefix(text[i:], []byte("/*")): - // Match BlockState's sliding-window scan so both agree on overlapping delimiters. - depth := 1 - i += 2 - for i < len(text) && depth > 0 { - switch { - case bytes.HasPrefix(text[i-1:], []byte("/*")): - depth++ - case bytes.HasPrefix(text[i-1:], []byte("*/")): - depth-- - } - i++ - } - if depth > 0 { - return true - } - default: - return false - } - } - return true -} - -// Opened a line comment -type CommentState struct{} - -func (s *CommentState) Next(r rune, data []byte) State { - if r == '-' { - // No characters are escaped in comments, which is the same as dollar - return &DollarState{delimiter: []byte{'\n'}} - } - // Break out of comment state - state := &ReadyState{} - return state.Next(r, data) -} - -// Opened a block comment -type BlockState struct { - depth int -} - -func (s *BlockState) Next(r rune, data []byte) State { - const open = "/*" - const close = "*/" - window := data[len(data)-2:] - if bytes.Equal(window, []byte(open)) { - s.depth += 1 - return s - } - if s.depth == 0 { - // Break out of block state - state := &ReadyState{} - return state.Next(r, data) - } - if bytes.Equal(window, []byte(close)) { - s.depth -= 1 - if s.depth == 0 { - return &ReadyState{} - } - } - return s -} - -// Opened a single quote ' or double quote " -type QuoteState struct { - delimiter rune - escape bool -} - -func (s *QuoteState) Next(r rune, data []byte) State { - if s.escape { - // Preserve escaped quote '' - if r == s.delimiter { - s.escape = false - return s - } - // Break out of quote state - state := &ReadyState{} - return state.Next(r, data) - } - if r == s.delimiter { - s.escape = true - } - return s -} - -// Opened a dollar quote, no characters are ever esacped. -type DollarState struct { - delimiter []byte -} - -func (s *DollarState) Next(r rune, data []byte) State { - window := data[len(data)-len(s.delimiter):] - if bytes.Equal(window, s.delimiter) { - // Break out of dollar state - return &ReadyState{} - } - return s -} - -// Opened a tag, ie. $tag$ -type TagState struct { - offset int -} - -func (s *TagState) Next(r rune, data []byte) State { - if r == '$' { - // Make a copy since the data slice may be overwritten - tag := data[s.offset:] - dollar := DollarState{ - delimiter: make([]byte, len(tag)), - } - copy(dollar.delimiter, tag) - return &dollar - } - // Valid tag: https://www.postgresql.org/docs/current/sql-syntax-lexical.html - if isIdentifierRune(r) { - return s - } - // Break out of tag state - state := &ReadyState{} - return state.Next(r, data) -} - -// Opened a \ escape -type EscapeState struct{} - -func (s *EscapeState) Next(r rune, data []byte) State { - return &ReadyState{} -} - -// Opened a parenthesis group -type ParenState struct { - prev State -} - -func (s *ParenState) Next(r rune, data []byte) State { - curr := s.prev.Next(r, data) - if curr == nil { - s.prev = &ReadyState{} - return s - } - s.prev = curr - if _, ok := s.prev.(*ReadyState); !ok { - return s - } - if r == ')' { - return &ReadyState{} - } - return s -} - -// Opened BEGIN ATOMIC function body -type AtomicState struct { - prev State - pendingEnd bool - statementStart int - statementHasContent bool -} - -func (s *AtomicState) Next(r rune, data []byte) State { - pendingEnd := s.pendingEnd - s.pendingEnd = false - if pendingEnd && !isIdentifierRune(r) { - return (&ReadyState{}).Next(r, data) - } - // An END inside a nested quote/comment doesn't count. - curr := s.prev.Next(r, data) - if curr == nil { - s.prev = &ReadyState{} - s.statementStart = len(data) - s.statementHasContent = false - return s - } - s.prev = curr - if _, ok := s.prev.(*ReadyState); !ok { - return s - } - // PostgreSQL requires each inner statement to end with ';', so the closing END is - // always the first token of a statement; a later END is expression text. - if !s.statementHasContent && endsWithKeyword(data, END_ATOMIC) { - if isCommentsAndWhitespace(data[s.statementStart : len(data)-len(END_ATOMIC)]) { - s.pendingEnd = true - } else { - s.statementHasContent = true - } - } - return s -} diff --git a/apps/cli-go/pkg/parser/state_test.go b/apps/cli-go/pkg/parser/state_test.go deleted file mode 100644 index 921a33b852..0000000000 --- a/apps/cli-go/pkg/parser/state_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package parser - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func checkSplit(t *testing.T, sql []string) { - input := strings.Join(sql, "") - output, err := Split(strings.NewReader(input)) - require.NoError(t, err) - assert.ElementsMatch(t, sql, output) -} - -func TestLineComment(t *testing.T) { - t.Run("after separator", func(t *testing.T) { - sql := []string{"END;", "-- comment"} - checkSplit(t, sql) - }) - - t.Run("before separator", func(t *testing.T) { - sql := []string{"SELECT --; 1"} - checkSplit(t, sql) - }) - - t.Run("not started", func(t *testing.T) { - sql := []string{"- ;", "END"} - checkSplit(t, sql) - }) - - t.Run("between lines", func(t *testing.T) { - sql := []string{"-- /* \n;", " */ END"} - checkSplit(t, sql) - }) -} - -func TestBlockComment(t *testing.T) { - t.Run("contains separator", func(t *testing.T) { - sql := []string{"SELECT /* ; */ 1;"} - checkSplit(t, sql) - }) - - t.Run("nested block", func(t *testing.T) { - sql := []string{"SELECT /*; /*;*/ ;*/ 1"} - checkSplit(t, sql) - }) - - t.Run("not started", func(t *testing.T) { - sql := []string{"/ * ;", " */ END"} - checkSplit(t, sql) - }) -} - -func TestSeparator(t *testing.T) { - t.Run("no spaces", func(t *testing.T) { - sql := []string{";", "END;", ";"} - checkSplit(t, sql) - }) - - t.Run("between spaces", func(t *testing.T) { - sql := []string{"BEGIN ;", " END"} - checkSplit(t, sql) - }) - - t.Run("backslash escaped", func(t *testing.T) { - sql := []string{"\\;;", "\\;"} - checkSplit(t, sql) - }) -} - -func TestDollarQuote(t *testing.T) { - t.Run("named tag", func(t *testing.T) { - sql := []string{"$tag$ any ; string$tag$"} - checkSplit(t, sql) - }) - - t.Run("non-ASCII named tag", func(t *testing.T) { - for _, tag := range []string{"a²", "a😀", "á"} { - t.Run(tag, func(t *testing.T) { - checkSplit(t, []string{"$" + tag + "$ any ; END; string$" + tag + "$;", " SELECT 2;"}) - }) - } - }) - - t.Run("anonymous tag", func(t *testing.T) { - sql := []string{"$$\"Dane's horse\"$$"} - checkSplit(t, sql) - }) - - t.Run("not started", func(t *testing.T) { - sql := []string{"SELECT \"$\";", " $$"} - checkSplit(t, sql) - }) -} - -func TestSingleQuote(t *testing.T) { - t.Run("escapes separator", func(t *testing.T) { - sql := []string{"SELECT ';' 1"} - checkSplit(t, sql) - }) - - t.Run("preserves single quote", func(t *testing.T) { - sql := []string{"SELECT ';'';' 1"} - checkSplit(t, sql) - }) - - t.Run("literal backslash", func(t *testing.T) { - sql := []string{"SELECT '\\';", " 1'"} - checkSplit(t, sql) - }) -} - -func TestDoubleQuote(t *testing.T) { - t.Run("escapes separator", func(t *testing.T) { - sql := []string{`CREATE POLICY "cats;dogs" on cats_dogs;`, " END"} - checkSplit(t, sql) - }) - - t.Run("preserves single quote", func(t *testing.T) { - sql := []string{`CREATE POLICY "cat's and dog's" on cats_dogs;`, " END"} - checkSplit(t, sql) - }) - - t.Run("preserves double quote", func(t *testing.T) { - sql := []string{`CREATE POLICY "pet""name" on pets;`, " END"} - checkSplit(t, sql) - }) -} - -func TestParentheses(t *testing.T) { - t.Run("preserves parentheses", func(t *testing.T) { - sql := []string{ - `CREATE RULE notify_me AS ON UPDATE TO mytable DO (NOTIFY mytable; SELECT "1");`, - `SELECT 1`, - } - checkSplit(t, sql) - }) - - t.Run("ignores literals", func(t *testing.T) { - sql := []string{`CREATE RULE notify_me AS ON UPDATE TO mytable DO (-- ) -SELECT ')'; --- ) -) -`} - checkSplit(t, sql) - }) -} - -func TestBeginAtomic(t *testing.T) { - t.Run("inline body", func(t *testing.T) { - sql := []string{`CREATE FUNCTION add(a integer, b integer) RETURNS integer -LANGUAGE SQL -IMMUTABLE STRICT PARALLEL SAFE -RETURNS NULL ON NULL INPUT -BEGIN ATOMIC - SELECT 'add'; - SELECT a + b; -END;`} - checkSplit(t, sql) - }) - - t.Run("case insenstive", func(t *testing.T) { - sql := []string{"begin atomic; select 'end'; end"} - checkSplit(t, sql) - }) - - t.Run("ignores literals", func(t *testing.T) { - sql := []string{`CREATE FUNCTION test() BEGIN -ATOMIC-- END; --- END; -END -;`} - checkSplit(t, sql) - }) - - t.Run("ignores atomic in identifiers", func(t *testing.T) { - names := []string{ - "fn_atomic", - "atomic_fn", - "my_atomic_thing", - "xatomicx", - "fn_ATomiC", - } - for _, name := range names { - t.Run(name, func(t *testing.T) { - sql := []string{ - `CREATE OR REPLACE FUNCTION ` + name + `() -RETURNS void LANGUAGE plpgsql AS $$ -BEGIN - NULL; -END; -$$;`, - ` -SELECT 1;`, - } - checkSplit(t, sql) - }) - } - }) - - t.Run("ignores end inside identifiers", func(t *testing.T) { - for _, name := range []string{"pending", "pending_change", "append", "legend", "𐐀end", "😀end", "́end", "²end", "pending$$foo$"} { - t.Run(name, func(t *testing.T) { - body := `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1 AS ` + name + `; END;` - checkSplit(t, []string{body, ` SELECT 2;`}) - }) - } - }) - - t.Run("ignores identifiers starting with end", func(t *testing.T) { - for _, name := range []string{"endpoint", "end_date", "ended_at", "end𐐀", "end😀", "end́", "end²", "end$$foo$"} { - t.Run(name, func(t *testing.T) { - body := `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT ` + name + `; SELECT 1; END;` - checkSplit(t, []string{body, ` SELECT 2;`}) - }) - } - }) - - t.Run("ignores end inside inner statements", func(t *testing.T) { - for _, expr := range []string{ - "CASE WHEN true THEN 1 ELSE 0 END", - "case when true then 1 end", - "CASE WHEN true THEN 1 END AS ended", - "CASE WHEN CASE WHEN true THEN true END THEN 1 END", - "(CASE WHEN (true) THEN 1 END)", - "coalesce(CASE WHEN length('a') > 0 THEN 1 END, 0)", - "CASE(1)WHEN 1 THEN 1 END", - "1 AS case", - "1 case", - "1 AS end", - "1 end", - "'end'", - } { - t.Run(expr, func(t *testing.T) { - body := `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT ` + expr + `; SELECT 1; END;` - checkSplit(t, []string{body, ` SELECT 2;`}) - }) - } - }) - - t.Run("closes at end preceded only by comments", func(t *testing.T) { - for _, comment := range []string{"-- note END\n", "/* note; */ ", "\n/* a /* b; */ */ -- c\n"} { - t.Run(comment, func(t *testing.T) { - body := `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; ` + comment + `END;` - checkSplit(t, []string{body, ` SELECT 2;`}) - }) - } - }) - - t.Run("ignores non-ASCII begin atomic lookalikes", func(t *testing.T) { - // atomıc (dotless ı) case-folds to ATOMIC but is a plain identifier in SQL. - checkSplit(t, []string{"BEGIN atomıc;", " SELECT 'end';", " SELECT 2;"}) - }) - - t.Run("closes atomic body at end right after a positional parameter", func(t *testing.T) { - checkSplit(t, []string{"CREATE FUNCTION f(int) RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT $1;END;", " SELECT 2;"}) - }) - - t.Run("requires sql whitespace between begin and atomic", func(t *testing.T) { - for _, gap := range []string{"\u00A0", "\uFEFF", "\u0085"} { - t.Run(gap, func(t *testing.T) { - checkSplit(t, []string{"BEGIN " + gap + " ATOMIC;", " SELECT 1;", " end;", " SELECT 2;"}) - }) - } - }) - - t.Run("closes nested atomic body inside parentheses", func(t *testing.T) { - checkSplit(t, []string{"DO (BEGIN ATOMIC SELECT 1; END; );", " SELECT 2;"}) - }) - - t.Run("ignores end after overlapping block comment", func(t *testing.T) { - body := `CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ SELECT 2 END; SELECT 3; END;` - checkSplit(t, []string{body, ` SELECT 4;`}) - }) - - t.Run("does not treat schema-qualified atomic function names as begin atomic", func(t *testing.T) { - sql := []string{`CREATE OR REPLACE FUNCTION public.atomic_example() -RETURNS INTEGER -LANGUAGE plpgsql -AS $$ -BEGIN - RETURN 1; -END; -$$;`, - ` -GRANT EXECUTE ON FUNCTION public.atomic_example() TO authenticated;`, - } - checkSplit(t, sql) - }) -} diff --git a/apps/cli-go/pkg/parser/testdata/all.sql b/apps/cli-go/pkg/parser/testdata/all.sql deleted file mode 100644 index c63948869c..0000000000 --- a/apps/cli-go/pkg/parser/testdata/all.sql +++ /dev/null @@ -1,140 +0,0 @@ -CREATE SCHEMA "blocks"; -CREATE TABLE "blocks"."block" -( - uuid UUID PRIMARY KEY NOT NULL, - settings_type CHARACTER VARYING NOT NULL, - settings JSONB NOT NULL, - title CHARACTER VARYING, - view_type CHARACTER VARYING, - enabled BOOL DEFAULT TRUE NOT NULL, - visibility_settings JSONB -); - -CREATE UNIQUE INDEX "block_uuid_uindex" - ON "blocks"."block" (uuid); - -CREATE INDEX "enabled_index" - ON "blocks"."block" (enabled); - -CREATE TABLE "blocks"."placement" -( - location_name CHARACTER VARYING NOT NULL, - index INT NOT NULL, - block_uuid UUID NOT NULL, - scope_path CHARACTER VARYING NOT NULL, - inherited BOOL DEFAULT FALSE NOT NULL, - excluded_scopes CHARACTER VARYING [], - - -- NOTE: Because we UPDATE the primary (location_name, index) key on the placement-table, - -- the constraint behavior on the primary key is set to DEFERRABLE and INITIALLY IMMEDIATE. - -- - -- For more inforation, refer to this bug-report: - -- - -- https://www.postgresql.org/message-id/flat/20170322123053.1421.55154%40wrigleys.postgresql.org - - CONSTRAINT placement_location_name_index_pk PRIMARY KEY (location_name, index) DEFERRABLE INITIALLY IMMEDIATE, - CONSTRAINT placement_block_uuid_fk FOREIGN KEY (block_uuid) REFERENCES "blocks"."block" (uuid) ON DELETE CASCADE -); - -CREATE INDEX "placement_location_name_index_index" - ON "blocks".placement (location_name, index); - -CREATE INDEX "placement_scope_path_index" - ON "blocks".placement (scope_path); - -CREATE INDEX "placement_index_index" - ON "blocks".placement (index); - -CREATE TABLE "blocks"."location_hash" -( - location_name CHARACTER VARYING PRIMARY KEY NOT NULL, - hash CHARACTER VARYING NOT NULL -); - -CREATE TABLE "blocks"."child_blocks" -( - parent_uuid UUID NOT NULL, - column_index INT NOT NULL, - child_uuids UUID [] NOT NULL, - CONSTRAINT child_blocks_parent_uuid_index_pk PRIMARY KEY (parent_uuid, column_index), - CONSTRAINT child_blocks_block_uuid_fk FOREIGN KEY (parent_uuid) REFERENCES "blocks"."block" (uuid) ON DELETE CASCADE - - -- TODO add a trigger to sanitize child_uuids when a block record is deleted -); - -CREATE INDEX "child_blocks_parent_uuid_index" - ON "blocks"."child_blocks" (parent_uuid); - -CREATE TABLE "blocks"."user_bucket" -( - user_uuid UUID PRIMARY KEY NOT NULL, - block_uuids UUID [] NOT NULL -); - -CREATE UNIQUE INDEX "user_bucket_user_uuid_uindex" - ON "blocks"."user_bucket" (user_uuid); - -CREATE VIEW "blocks"."block_count" AS - SELECT - b."uuid" AS "block_uuid", - ( - (SELECT COUNT(*) - FROM "blocks"."placement" p - WHERE p."block_uuid" = b."uuid") - + - (SELECT COALESCE(SUM((SELECT COUNT(*) - FROM unnest(c."child_uuids") cb - WHERE cb = b."uuid")), 0) - FROM "blocks"."child_blocks" c) - ) AS "ref_count", - (SELECT COUNT(*) - FROM "blocks"."user_bucket" k - WHERE "uuid" = ANY (k."block_uuids")) AS "user_count" - FROM "blocks"."block" b; - -CREATE OR REPLACE FUNCTION "blocks".delete_orphaned_blocks() - RETURNS TRIGGER -AS $$ -BEGIN - LOOP - -- Note that RETURN QUERY does not return from the function - it works - -- more like the yield-statement in PHP, in that records from the - -- DELETE..RETURNING statement are returned, and execution then - -- resumes from the following statement. - - DELETE FROM "blocks"."block" b - WHERE b.uuid IN ( - SELECT c.block_uuid - FROM "blocks"."block_count" c - WHERE c.ref_count = 0 AND c.user_count = 0 - ); - - -- The FOUND flag is set TRUE/FALSE after executing a query - so we - -- EXIT from the LOOP block when the DELETE..RETURNING statement does - -- not delete and return any records. - - EXIT WHEN NOT FOUND; - END LOOP; - - RETURN NULL; -END; -$$ -LANGUAGE plpgsql; - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."user_bucket" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."placement" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."child_blocks" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); diff --git a/apps/cli-go/pkg/parser/testdata/split_00.sql b/apps/cli-go/pkg/parser/testdata/split_00.sql deleted file mode 100644 index 78a47bc16e..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_00.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE SCHEMA "blocks"; \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_01.sql b/apps/cli-go/pkg/parser/testdata/split_01.sql deleted file mode 100644 index a877e55f0d..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_01.sql +++ /dev/null @@ -1,11 +0,0 @@ - -CREATE TABLE "blocks"."block" -( - uuid UUID PRIMARY KEY NOT NULL, - settings_type CHARACTER VARYING NOT NULL, - settings JSONB NOT NULL, - title CHARACTER VARYING, - view_type CHARACTER VARYING, - enabled BOOL DEFAULT TRUE NOT NULL, - visibility_settings JSONB -); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_02.sql b/apps/cli-go/pkg/parser/testdata/split_02.sql deleted file mode 100644 index a4eecb94c7..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_02.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE UNIQUE INDEX "block_uuid_uindex" - ON "blocks"."block" (uuid); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_03.sql b/apps/cli-go/pkg/parser/testdata/split_03.sql deleted file mode 100644 index 20eec9067c..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_03.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE INDEX "enabled_index" - ON "blocks"."block" (enabled); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_04.sql b/apps/cli-go/pkg/parser/testdata/split_04.sql deleted file mode 100644 index 493f7aac38..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_04.sql +++ /dev/null @@ -1,21 +0,0 @@ - - -CREATE TABLE "blocks"."placement" -( - location_name CHARACTER VARYING NOT NULL, - index INT NOT NULL, - block_uuid UUID NOT NULL, - scope_path CHARACTER VARYING NOT NULL, - inherited BOOL DEFAULT FALSE NOT NULL, - excluded_scopes CHARACTER VARYING [], - - -- NOTE: Because we UPDATE the primary (location_name, index) key on the placement-table, - -- the constraint behavior on the primary key is set to DEFERRABLE and INITIALLY IMMEDIATE. - -- - -- For more inforation, refer to this bug-report: - -- - -- https://www.postgresql.org/message-id/flat/20170322123053.1421.55154%40wrigleys.postgresql.org - - CONSTRAINT placement_location_name_index_pk PRIMARY KEY (location_name, index) DEFERRABLE INITIALLY IMMEDIATE, - CONSTRAINT placement_block_uuid_fk FOREIGN KEY (block_uuid) REFERENCES "blocks"."block" (uuid) ON DELETE CASCADE -); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_05.sql b/apps/cli-go/pkg/parser/testdata/split_05.sql deleted file mode 100644 index 003b969530..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_05.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE INDEX "placement_location_name_index_index" - ON "blocks".placement (location_name, index); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_06.sql b/apps/cli-go/pkg/parser/testdata/split_06.sql deleted file mode 100644 index c9b41508fb..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_06.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE INDEX "placement_scope_path_index" - ON "blocks".placement (scope_path); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_07.sql b/apps/cli-go/pkg/parser/testdata/split_07.sql deleted file mode 100644 index 824464ab6d..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_07.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE INDEX "placement_index_index" - ON "blocks".placement (index); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_08.sql b/apps/cli-go/pkg/parser/testdata/split_08.sql deleted file mode 100644 index db30a5dd52..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_08.sql +++ /dev/null @@ -1,7 +0,0 @@ - - -CREATE TABLE "blocks"."location_hash" -( - location_name CHARACTER VARYING PRIMARY KEY NOT NULL, - hash CHARACTER VARYING NOT NULL -); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_09.sql b/apps/cli-go/pkg/parser/testdata/split_09.sql deleted file mode 100644 index fad90ece22..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_09.sql +++ /dev/null @@ -1,12 +0,0 @@ - - -CREATE TABLE "blocks"."child_blocks" -( - parent_uuid UUID NOT NULL, - column_index INT NOT NULL, - child_uuids UUID [] NOT NULL, - CONSTRAINT child_blocks_parent_uuid_index_pk PRIMARY KEY (parent_uuid, column_index), - CONSTRAINT child_blocks_block_uuid_fk FOREIGN KEY (parent_uuid) REFERENCES "blocks"."block" (uuid) ON DELETE CASCADE - - -- TODO add a trigger to sanitize child_uuids when a block record is deleted -); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_10.sql b/apps/cli-go/pkg/parser/testdata/split_10.sql deleted file mode 100644 index 4abf69d4a0..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_10.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE INDEX "child_blocks_parent_uuid_index" - ON "blocks"."child_blocks" (parent_uuid); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_11.sql b/apps/cli-go/pkg/parser/testdata/split_11.sql deleted file mode 100644 index 91fa4a3250..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_11.sql +++ /dev/null @@ -1,7 +0,0 @@ - - -CREATE TABLE "blocks"."user_bucket" -( - user_uuid UUID PRIMARY KEY NOT NULL, - block_uuids UUID [] NOT NULL -); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_12.sql b/apps/cli-go/pkg/parser/testdata/split_12.sql deleted file mode 100644 index 835d649b37..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_12.sql +++ /dev/null @@ -1,4 +0,0 @@ - - -CREATE UNIQUE INDEX "user_bucket_user_uuid_uindex" - ON "blocks"."user_bucket" (user_uuid); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_13.sql b/apps/cli-go/pkg/parser/testdata/split_13.sql deleted file mode 100644 index c39a217906..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_13.sql +++ /dev/null @@ -1,19 +0,0 @@ - - -CREATE VIEW "blocks"."block_count" AS - SELECT - b."uuid" AS "block_uuid", - ( - (SELECT COUNT(*) - FROM "blocks"."placement" p - WHERE p."block_uuid" = b."uuid") - + - (SELECT COALESCE(SUM((SELECT COUNT(*) - FROM unnest(c."child_uuids") cb - WHERE cb = b."uuid")), 0) - FROM "blocks"."child_blocks" c) - ) AS "ref_count", - (SELECT COUNT(*) - FROM "blocks"."user_bucket" k - WHERE "uuid" = ANY (k."block_uuids")) AS "user_count" - FROM "blocks"."block" b; \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_14.sql b/apps/cli-go/pkg/parser/testdata/split_14.sql deleted file mode 100644 index 8e04845fba..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_14.sql +++ /dev/null @@ -1,30 +0,0 @@ - - -CREATE OR REPLACE FUNCTION "blocks".delete_orphaned_blocks() - RETURNS TRIGGER -AS $$ -BEGIN - LOOP - -- Note that RETURN QUERY does not return from the function - it works - -- more like the yield-statement in PHP, in that records from the - -- DELETE..RETURNING statement are returned, and execution then - -- resumes from the following statement. - - DELETE FROM "blocks"."block" b - WHERE b.uuid IN ( - SELECT c.block_uuid - FROM "blocks"."block_count" c - WHERE c.ref_count = 0 AND c.user_count = 0 - ); - - -- The FOUND flag is set TRUE/FALSE after executing a query - so we - -- EXIT from the LOOP block when the DELETE..RETURNING statement does - -- not delete and return any records. - - EXIT WHEN NOT FOUND; - END LOOP; - - RETURN NULL; -END; -$$ -LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_15.sql b/apps/cli-go/pkg/parser/testdata/split_15.sql deleted file mode 100644 index 58920aae41..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_15.sql +++ /dev/null @@ -1,7 +0,0 @@ - - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."user_bucket" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_16.sql b/apps/cli-go/pkg/parser/testdata/split_16.sql deleted file mode 100644 index 10cdd16a18..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_16.sql +++ /dev/null @@ -1,7 +0,0 @@ - - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."placement" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/testdata/split_17.sql b/apps/cli-go/pkg/parser/testdata/split_17.sql deleted file mode 100644 index a8c67d265d..0000000000 --- a/apps/cli-go/pkg/parser/testdata/split_17.sql +++ /dev/null @@ -1,7 +0,0 @@ - - -CREATE TRIGGER delete_orphans -AFTER UPDATE OR DELETE - ON "blocks"."child_blocks" -FOR EACH STATEMENT -EXECUTE PROCEDURE "blocks".delete_orphaned_blocks(); \ No newline at end of file diff --git a/apps/cli-go/pkg/parser/token.go b/apps/cli-go/pkg/parser/token.go deleted file mode 100644 index db00843424..0000000000 --- a/apps/cli-go/pkg/parser/token.go +++ /dev/null @@ -1,119 +0,0 @@ -package parser - -import ( - "bufio" - "io" - "strings" - "unicode/utf8" - - "github.com/go-errors/errors" - "github.com/spf13/viper" - "github.com/supabase/cli/pkg/cast" -) - -// Equal to `startBufSize` from `bufio/scan.go` -const startBufSize = 4096 - -// MaxScannerCapacity defaults to 64 * 1024 which is not enough for certain lines -// containing e.g. geographical data. 256K ought to be enough for anybody... -var MaxScannerCapacity = 256 * 1024 - -// State transition table for tokenizer: -// -// Ready -> Ready (default) -// Ready -> Error (on invalid syntax) -// Ready -> Done (on ;, emit token) -// Ready -> Done (on EOF, emit token) -// -// Ready -> Comment (on --) -// Comment -> Comment (default) -// Comment -> Ready (on \n) -// -// Ready -> Block (on /*) -// Block -> Block (on /*, +-depth) -// Block -> Ready (on */, depth 0) -// -// Ready -> Quote (on ') -// Quote -> Quote (on '', default) -// Quote -> Ready (on ') -// -// Ready -> Dollar (on $tag$) -// Dollar -> Dollar (default) -// Dollar -> Ready (on $tag$) -// -// Ready -> Escape (on \) -// Escape -> Ready (on next) -type tokenizer struct { - state State - last int -} - -func (t *tokenizer) ScanToken(data []byte, atEOF bool) (advance int, token []byte, err error) { - // If we requested more data, resume from last position. - for width := 1; t.last < len(data); t.last += width { - r, width := utf8.DecodeRune(data[t.last:]) - end := t.last + width - t.state = t.state.Next(r, data[:end]) - // Emit token - if t.state == nil { - t.last = 0 - t.state = &ReadyState{} - return end, data[:end], nil - } - } - if !atEOF || len(data) == 0 { - // Request more data or end the stream - return 0, nil, nil - } - // We're at EOF. If we have a final, non-terminated token, return it. - return len(data), data, nil -} - -// Use bufio.Scanner to split a PostgreSQL string into multiple statements. -// -// The core problem is to figure out whether the current ; separator is inside -// an escaped string literal. PostgreSQL has multiple ways of opening a string -// literal, $$, ', --, /*, etc. We use a FSM to guarantee these states are -// entered exclusively. If not in one of the above escape states, the next ; -// token can be parsed as statement separator. -// -// Each statement is split as it is, without removing comments or white spaces. -func Split(sql io.Reader, transform ...func(string) string) (stats []string, err error) { - t := tokenizer{state: &ReadyState{}} - scanner := bufio.NewScanner(sql) - - // Increase scanner capacity to support very long lines containing e.g. geodata - buf := make([]byte, startBufSize) - maxbuf := cast.UintToInt(viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")) - if maxbuf == 0 { - maxbuf = MaxScannerCapacity - } - scanner.Buffer(buf, maxbuf) - scanner.Split(t.ScanToken) - - var token string - for scanner.Scan() { - token = scanner.Text() - trim := token - for _, apply := range transform { - trim = apply(trim) - } - if len(trim) > 0 { - stats = append(stats, trim) - } - } - err = scanner.Err() - if err != nil { - err = errors.Errorf("%w\nAfter statement %d: %s", err, len(stats), token) - } - if errors.Is(err, bufio.ErrTooLong) { - err = errors.Errorf("%w\nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is %dKB)", err, maxbuf>>10) - } - return stats, err -} - -func SplitAndTrim(sql io.Reader) (stats []string, err error) { - return Split(sql, func(token string) string { - return strings.TrimRight(token, ";") - }, strings.TrimSpace) -} diff --git a/apps/cli-go/pkg/parser/token_test.go b/apps/cli-go/pkg/parser/token_test.go deleted file mode 100644 index 566b11867b..0000000000 --- a/apps/cli-go/pkg/parser/token_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package parser - -import ( - "bufio" - _ "embed" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSplit(t *testing.T) { - const testdata = "testdata" - - var fixture []string - require.NoError(t, filepath.WalkDir(testdata, func(path string, f fs.DirEntry, err error) error { - if err != nil { - return err - } - if !strings.HasPrefix(f.Name(), "split_") { - return nil - } - contents, err := os.ReadFile(path) - if err != nil { - return err - } - fixture = append(fixture, string(contents)) - return nil - })) - require.Len(t, fixture, 18) - fixture = append(fixture, "\n") - - sql, err := os.Open(filepath.Join(testdata, "all.sql")) - require.NoError(t, err) - stats, err := Split(sql) - require.NoError(t, err) - - assert.ElementsMatch(t, fixture, stats) -} - -func TestSplitAndTrim(t *testing.T) { - sql := "\tBEGIN; " + strings.Repeat("a", MaxScannerCapacity) - stats, err := SplitAndTrim(strings.NewReader(sql)) - // Check error - assert.ErrorIs(t, err, bufio.ErrTooLong) - assert.ErrorContains(t, err, "After statement 1: \tBEGIN;") - assert.ElementsMatch(t, []string{"BEGIN"}, stats) -} diff --git a/apps/cli-go/pkg/pgtest/mock.go b/apps/cli-go/pkg/pgtest/mock.go deleted file mode 100644 index 999115b051..0000000000 --- a/apps/cli-go/pkg/pgtest/mock.go +++ /dev/null @@ -1,331 +0,0 @@ -package pgtest - -import ( - "context" - "fmt" - "net" - "reflect" - "testing" - "time" - - "github.com/jackc/pgmock" - "github.com/jackc/pgproto3/v2" - "github.com/jackc/pgtype" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/pgxv5" - "google.golang.org/grpc/test/bufconn" -) - -type MockConn struct { - client *pgx.Conn - - // Duplex server listener backed by in-memory buffer - server *bufconn.Listener - - // Mock server requests and responses - script pgmock.Script - - // Status parameters emitted by postgres on first connect - status map[string]string - - // Channel for reporting all server error - errChan chan error -} - -func (r *MockConn) getStartupMessage(config *pgx.ConnConfig) []pgmock.Step { - var steps []pgmock.Step - // Add auth message - params := map[string]string{"user": config.User} - if len(config.Database) > 0 { - params["database"] = config.Database - } - steps = append( - steps, - pgmock.ExpectMessage(&pgproto3.StartupMessage{ - ProtocolVersion: pgproto3.ProtocolVersionNumber, - Parameters: params, - }), - pgmock.SendMessage(&pgproto3.AuthenticationOk{}), - ) - // Add status message - r.status["session_authorization"] = config.User - for key, value := range r.status { - steps = append(steps, pgmock.SendMessage(&pgproto3.ParameterStatus{Name: key, Value: value})) - } - // Add ready message - steps = append( - steps, - pgmock.SendMessage(&pgproto3.BackendKeyData{ProcessID: 0, SecretKey: 0}), - pgmock.SendMessage(&pgproto3.ReadyForQuery{TxStatus: 'I'}), - ) - return steps -} - -// Configures pgx to use the mock dialer. -// -// The mock dialer provides a full duplex net.Conn backed by an in-memory buffer. -// It is implemented by grcp/test/bufconn package. -func (r *MockConn) Intercept(config *pgx.ConnConfig) { - // Override config for test - config.DialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) { - return r.server.DialContext(ctx) - } - config.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) { - return []string{"127.0.0.1"}, nil - } - config.TLSConfig = nil - // Add startup message - r.script.Steps = append(r.getStartupMessage(config), r.script.Steps...) -} - -// Adds a simple query or prepared statement to the mock connection. -func (r *MockConn) Query(sql string, args ...any) *MockConn { - var oids []uint32 - var params [][]byte - for _, v := range args { - if value, oid := r.encodeValueArg(v); oid > 0 { - params = append(params, value) - oids = append(oids, oid) - } - } - r.script.Steps = append(r.script.Steps, ExpectQuery(sql, params, oids)) - return r -} - -// SimpleQuery adds a simple-protocol query to the mock connection. -func (r *MockConn) SimpleQuery(sql string) *MockConn { - r.script.Steps = append(r.script.Steps, ExpectSimpleQuery(sql)) - return r -} - -func (r *MockConn) encodeValueArg(v any) (value []byte, oid uint32) { - if v == nil { - return nil, pgtype.TextArrayOID - } - dt, ok := ci.DataTypeForValue(v) - if !ok { - r.errChan <- fmt.Errorf("no suitable type for arg: %v", v) - return nil, 0 - } - if err := dt.Value.Set(v); err != nil { - r.errChan <- fmt.Errorf("failed to set value: %w", err) - return nil, 0 - } - var err error - switch dt.OID { - case pgtype.TextOID: - value, err = (dt.Value).(pgtype.TextEncoder).EncodeText(ci, []byte{}) - default: - value, err = (dt.Value).(pgtype.BinaryEncoder).EncodeBinary(ci, []byte{}) - } - if err != nil { - r.errChan <- fmt.Errorf("failed to encode arg: %w", err) - return nil, 0 - } - return value, dt.OID -} - -func getDataTypeSize(v any) int16 { - t := reflect.TypeOf(v) - k := t.Kind() - if k < reflect.Int || k > reflect.Complex128 { - return -1 - } - return int16(t.Size()) -} - -func (r *MockConn) lastQuery() *extendedQueryStep { - return r.script.Steps[len(r.script.Steps)-1].(*extendedQueryStep) -} - -// Adds a server reply using binary or text protocol format. -// -// TODO: support prepared statements when using binary protocol -func (r *MockConn) Reply(tag string, rows ...any) *MockConn { - q := r.lastQuery() - // Add field description - if len(rows) > 0 { - var desc pgproto3.RowDescription - if arr, ok := rows[0].([]any); ok { - for i, v := range arr { - name := fmt.Sprintf("c_%02d", i) - if fd := toFieldDescription(v); fd != nil { - fd.Name = []byte(name) - desc.Fields = append(desc.Fields, *fd) - } else { - r.errChan <- fmt.Errorf("failed to describe field: %s", name) - } - } - } else if t := reflect.TypeOf(rows[0]); t.Kind() == reflect.Struct { - s := reflect.ValueOf(rows[0]) - for i := 0; i < s.NumField(); i++ { - name := pgxv5.GetColumnName(t.Field(i)) - if len(name) == 0 { - continue - } - v := s.Field(i).Interface() - if fd := toFieldDescription(v); fd != nil { - fd.Name = []byte(name) - desc.Fields = append(desc.Fields, *fd) - } else { - r.errChan <- fmt.Errorf("failed to describe field: %s", name) - } - } - } else { - r.errChan <- fmt.Errorf("reply type must be one of [array, struct], found: %s", t.Kind()) - } - q.reply.Steps = append(q.reply.Steps, pgmock.SendMessage(&desc)) - } else { - // No data is optional, but we add for completeness - q.reply.Steps = append(q.reply.Steps, pgmock.SendMessage(&pgproto3.NoData{})) - } - // Add row data - for _, data := range rows { - var dr pgproto3.DataRow - if arr, ok := data.([]any); ok { - for _, v := range arr { - if value, oid := r.encodeValueArg(v); oid > 0 { - dr.Values = append(dr.Values, value) - } - } - } else if t := reflect.TypeOf(data); t.Kind() == reflect.Struct { - s := reflect.ValueOf(rows[0]) - for i := 0; i < s.NumField(); i++ { - if name := pgxv5.GetColumnName(t.Field(i)); len(name) == 0 { - continue - } - v := s.Field(i).Interface() - if value, oid := r.encodeValueArg(v); oid > 0 { - dr.Values = append(dr.Values, value) - } - } - } else { - r.errChan <- fmt.Errorf("invalid reply value: %v", data) - } - q.reply.Steps = append(q.reply.Steps, pgmock.SendMessage(&dr)) - } - // Add completion message - var complete pgproto3.BackendMessage - if tag == "" { - complete = &pgproto3.EmptyQueryResponse{} - } else { - complete = &pgproto3.CommandComplete{CommandTag: []byte(tag)} - } - q.reply.Steps = append(q.reply.Steps, pgmock.SendMessage(complete)) - return r -} - -func toFieldDescription(v any) *pgproto3.FieldDescription { - if dt, ok := ci.DataTypeForValue(v); ok { - size := getDataTypeSize(v) - format := ci.ParamFormatCodeForOID(dt.OID) - return &pgproto3.FieldDescription{ - TableOID: 17131, - TableAttributeNumber: 1, - DataTypeOID: dt.OID, - DataTypeSize: size, - TypeModifier: -1, - Format: format, - } - } - return nil -} - -// Simulates an error reply from the server. -// -// TODO: simulate a notice reply -func (r *MockConn) ReplyError(code, message string) *MockConn { - q := r.lastQuery() - q.reply.Steps = append( - q.reply.Steps, - pgmock.SendMessage(&pgproto3.ErrorResponse{ - Severity: "ERROR", - SeverityUnlocalized: "ERROR", - Code: code, - Message: message, - }), - ) - return r -} - -func (r *MockConn) Close(t *testing.T) { - if r.client != nil { - if err := r.client.Close(context.Background()); err != nil { - t.Errorf("failed to close client: %v", err) - } - } - for err := range r.errChan { - t.Errorf("pgmock error:\n%v", err) - } - if err := r.server.Close(); err != nil { - t.Fatalf("failed to close server: %v", err) - } -} - -func (r *MockConn) MockClient(t *testing.T, opts ...func(*pgx.ConnConfig)) *pgx.Conn { - if r.client != nil { - return r.client - } - opts = append(opts, r.Intercept, func(cc *pgx.ConnConfig) { - cc.ConnectTimeout = time.Second * 2 - }) - var err error - if r.client, err = pgxv5.Connect(context.Background(), "", opts...); err != nil { - t.Errorf("failed to connect: %v", err) - } - return r.client -} - -func NewWithStatus(status map[string]string) *MockConn { - const bufSize = 1024 * 1024 - mock := MockConn{ - server: bufconn.Listen(bufSize), - status: status, - errChan: make(chan error, 10), - } - // Start server in background - const timeout = time.Millisecond * 450 - go func() { - defer close(mock.errChan) - // Block until we've opened a TCP connection - conn, err := mock.server.Accept() - if err != nil { - mock.errChan <- err - return - } - defer conn.Close() - // Prevent server from hanging the test - err = conn.SetDeadline(time.Now().Add(timeout)) - if err != nil { - mock.errChan <- err - return - } - // Always expect clients to terminate the request - mock.script.Steps = append(mock.script.Steps, ExpectTerminate()) - err = mock.script.Run(pgproto3.NewBackend(pgproto3.NewChunkReader(conn), conn)) - if err != nil { - mock.errChan <- err - return - } - }() - - return &mock -} - -func NewConn() *MockConn { - status := map[string]string{ - "application_name": "", - "client_encoding": "UTF8", - "DateStyle": "ISO, MDY", - "default_transaction_read_only": "off", - "in_hot_standby": "off", - "integer_datetimes": "on", - "IntervalStyle": "postgres", - "is_superuser": "on", - "server_encoding": "UTF8", - "server_version": "14.3 (Debian 14.3-1.pgdg110+1)", - "standard_conforming_strings": "on", - "TimeZone": "UTC", - } - return NewWithStatus(status) -} diff --git a/apps/cli-go/pkg/pgtest/step.go b/apps/cli-go/pkg/pgtest/step.go deleted file mode 100644 index a13d20d890..0000000000 --- a/apps/cli-go/pkg/pgtest/step.go +++ /dev/null @@ -1,141 +0,0 @@ -package pgtest - -import ( - "reflect" - - "github.com/go-errors/errors" - "github.com/jackc/pgmock" - "github.com/jackc/pgproto3/v2" - "github.com/jackc/pgtype" -) - -var ci = pgtype.NewConnInfo() - -type extendedQueryStep struct { - sql string - params [][]byte - oids []uint32 - simpleOnly bool - reply pgmock.Script -} - -func (e *extendedQueryStep) Step(backend *pgproto3.Backend) error { - msg, err := getFrontendMessage(backend) - if err != nil { - return err - } - - // Handle simple query - want := &pgproto3.Query{String: e.sql} - if m, ok := msg.(*pgproto3.Query); ok && reflect.DeepEqual(m, want) { - e.reply.Steps = append(e.reply.Steps, pgmock.SendMessage(&pgproto3.ReadyForQuery{TxStatus: 'I'})) - return e.reply.Run(backend) - } - if e.simpleOnly { - return errors.Errorf("expected => %#v\nactual => %#v", want, msg) - } - - // Handle prepared statements, name can be dynamic: lrupsc_5_0 - if m, ok := msg.(*pgproto3.Parse); ok { - want := &pgproto3.Parse{Name: m.Name, Query: e.sql, ParameterOIDs: m.ParameterOIDs} - if !reflect.DeepEqual(m, want) { - return errors.Errorf("expected => %#v\nactual => %#v", want, m) - } - // Anonymous ps falls through - if m.Name != "" { - script := pgmock.Script{Steps: []pgmock.Step{ - pgmock.ExpectMessage(&pgproto3.Describe{ObjectType: 'S', Name: m.Name}), - pgmock.ExpectMessage(&pgproto3.Sync{}), - pgmock.SendMessage(&pgproto3.ParseComplete{}), - pgmock.SendMessage(&pgproto3.ParameterDescription{ParameterOIDs: e.oids}), - // Postgres responds pgproto3.RowDescription but it's optional for pgx - pgmock.SendMessage(&pgproto3.ReadyForQuery{TxStatus: 'I'}), - }} - if err := script.Run(backend); err != nil { - return err - } - } - // Expect bind command next - msg, err = backend.Receive() - if err != nil { - return err - } - } - - if m, ok := msg.(*pgproto3.Bind); ok { - var codes []int16 - for _, oid := range e.oids { - codes = append(codes, ci.ParamFormatCodeForOID(oid)) - } - want := &pgproto3.Bind{ - ParameterFormatCodes: codes, - Parameters: e.params, - ResultFormatCodes: []int16{}, - DestinationPortal: m.DestinationPortal, - PreparedStatement: m.PreparedStatement, - } - if !reflect.DeepEqual(m, want) { - return errors.Errorf("expected => %#v\nactual => %#v", want, msg) - } - e.reply.Steps = append([]pgmock.Step{ - pgmock.ExpectMessage(&pgproto3.Describe{ObjectType: 'P'}), - pgmock.ExpectMessage(&pgproto3.Execute{}), - pgmock.SendMessage(&pgproto3.ParseComplete{}), - pgmock.SendMessage(&pgproto3.BindComplete{}), - }, e.reply.Steps...) - return e.reply.Run(backend) - } - - return errors.Errorf("expected => %#v\nactual => %#v", want, msg) -} - -// Expects a SQL query in any form: simple, prepared, or anonymous. -func ExpectQuery(sql string, params [][]byte, oids []uint32) pgmock.Step { - return &extendedQueryStep{sql: sql, params: params, oids: oids} -} - -// ExpectSimpleQuery expects SQL through the simple query protocol. -func ExpectSimpleQuery(sql string) pgmock.Step { - return &extendedQueryStep{sql: sql, simpleOnly: true} -} - -type terminateStep struct{} - -func (e *terminateStep) Step(backend *pgproto3.Backend) error { - msg, err := getFrontendMessage(backend) - if err != nil { - return err - } - - // Handle simple query - if _, ok := msg.(*pgproto3.Terminate); ok { - return nil - } - - return errors.Errorf("expected => %#v\nactual => %#v", &pgproto3.Terminate{}, msg) -} - -func ExpectTerminate() pgmock.Step { - return &terminateStep{} -} - -func getFrontendMessage(backend *pgproto3.Backend) (pgproto3.FrontendMessage, error) { - msg, err := backend.Receive() - if err != nil { - return nil, err - } - - // Sync signals end of batch statements - if _, ok := msg.(*pgproto3.Sync); ok { - reply := pgmock.SendMessage(&pgproto3.ReadyForQuery{TxStatus: 'I'}) - if err := reply.Step(backend); err != nil { - return nil, err - } - msg, err = backend.Receive() - if err != nil { - return nil, err - } - } - - return msg, nil -} diff --git a/apps/cli-go/pkg/pgxv5/connect.go b/apps/cli-go/pkg/pgxv5/connect.go deleted file mode 100644 index 4ae754f24e..0000000000 --- a/apps/cli-go/pkg/pgxv5/connect.go +++ /dev/null @@ -1,42 +0,0 @@ -package pgxv5 - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" -) - -// Extends pgx.Connect with support for programmatically overriding parsed config -func Connect(ctx context.Context, connString string, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { - // Parse connection url - config, err := pgx.ParseConfig(connString) - if err != nil { - return nil, errors.Errorf("failed to parse connection string: %w", err) - } - config.OnNotice = func(pc *pgconn.PgConn, n *pgconn.Notice) { - if !shouldIgnore(n.Message) { - fmt.Fprintf(os.Stderr, "%s (%s): %s\n", n.Severity, n.Code, n.Message) - } - } - // Apply config overrides - for _, op := range options { - op(config) - } - // Connect to database - conn, err := pgx.ConnectConfig(ctx, config) - if err != nil { - return nil, errors.Errorf("failed to connect to postgres: %w", err) - } - return conn, nil -} - -func shouldIgnore(msg string) bool { - return strings.Contains(msg, `schema "supabase_migrations" already exists`) || - strings.Contains(msg, `relation "schema_migrations" already exists`) || - strings.Contains(msg, `relation "seed_files" already exists`) -} diff --git a/apps/cli-go/pkg/pgxv5/rows.go b/apps/cli-go/pkg/pgxv5/rows.go deleted file mode 100644 index 96aa16f5ac..0000000000 --- a/apps/cli-go/pkg/pgxv5/rows.go +++ /dev/null @@ -1,132 +0,0 @@ -// Backported from pgx/v5: https://github.com/jackc/pgx/blob/v5.3.1/rows.go#L408 -package pgxv5 - -import ( - "reflect" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgproto3/v2" - "github.com/jackc/pgx/v4" -) - -func CollectStrings(rows pgx.Rows) ([]string, error) { - defer rows.Close() - result := []string{} - for rows.Next() { - var version string - if err := rows.Scan(&version); err != nil { - return nil, errors.Errorf("failed to scan rows: %w", err) - } - result = append(result, version) - } - if err := rows.Err(); err != nil { - return nil, errors.Errorf("failed to parse rows: %w", err) - } - return result, nil -} - -// CollectRows iterates through rows, calling fn for each row, and collecting the results into a slice of T. -func CollectRows[T any](rows pgx.Rows) ([]T, error) { - defer rows.Close() - - slice := []T{} - - for rows.Next() { - var value T - if err := ScanRowToStruct(rows, &value); err != nil { - return nil, err - } - slice = append(slice, value) - } - - if err := rows.Err(); err != nil { - return nil, errors.Errorf("failed to collect rows: %w", err) - } - - return slice, nil -} - -func ScanRowToStruct(rows pgx.Rows, dst any) error { - dstValue := reflect.ValueOf(dst) - if dstValue.Kind() != reflect.Ptr { - return errors.Errorf("dst not a pointer") - } - - dstElemValue := dstValue.Elem() - scanTargets, err := appendScanTargets(dstElemValue, nil, rows.FieldDescriptions()) - if err != nil { - return err - } - - for i, t := range scanTargets { - if t == nil { - return errors.Errorf("struct doesn't have corresponding row field %s", rows.FieldDescriptions()[i].Name) - } - } - - if err := rows.Scan(scanTargets...); err != nil { - return errors.Errorf("failed to scan targets: %w", err) - } - return nil -} - -const structTagKey = "db" - -func fieldPosByName(fldDescs []pgproto3.FieldDescription, field string) (i int) { - i = -1 - for i, desc := range fldDescs { - if strings.EqualFold(string(desc.Name), field) { - return i - } - } - return -} - -func appendScanTargets(dstElemValue reflect.Value, scanTargets []any, fldDescs []pgproto3.FieldDescription) ([]any, error) { - var err error - dstElemType := dstElemValue.Type() - - if scanTargets == nil { - scanTargets = make([]any, len(fldDescs)) - } - - for i := 0; i < dstElemType.NumField(); i++ { - sf := dstElemType.Field(i) - if sf.PkgPath != "" && !sf.Anonymous { - // Field is unexported, skip it. - continue - } - // Handle anoymous struct embedding, but do not try to handle embedded pointers. - if sf.Anonymous && sf.Type.Kind() == reflect.Struct { - scanTargets, err = appendScanTargets(dstElemValue.Field(i), scanTargets, fldDescs) - if err != nil { - return nil, err - } - } else { - colName := GetColumnName(sf) - if len(colName) == 0 { - // Field is ignored, skip it. - continue - } - fpos := fieldPosByName(fldDescs, colName) - if fpos == -1 || fpos >= len(scanTargets) { - return nil, errors.Errorf("cannot find field %s in returned row", colName) - } - scanTargets[fpos] = dstElemValue.Field(i).Addr().Interface() - } - } - - return scanTargets, err -} - -func GetColumnName(sf reflect.StructField) string { - dbTag, dbTagPresent := sf.Tag.Lookup(structTagKey) - if !dbTagPresent { - return sf.Name - } - if dbTag = strings.Split(dbTag, ",")[0]; dbTag != "-" { - return dbTag - } - return "" -} diff --git a/apps/cli-go/pkg/queue/queue.go b/apps/cli-go/pkg/queue/queue.go deleted file mode 100644 index e427e01c53..0000000000 --- a/apps/cli-go/pkg/queue/queue.go +++ /dev/null @@ -1,57 +0,0 @@ -package queue - -import ( - "sync" - - "github.com/go-errors/errors" -) - -// JobQueue implements a background job processor using a single channel and wait group. -// -// The channel is initialised with a maximum number of concurrent workers. Adding a new job -// consumes a worker from the channel. After finishing a job, the worker is added back to -// the channel. When all workers are consumed, adding new job will block. -// -// Example usage: -// -// jq := NewJobQueue(5) -// err := jq.Put(func() error { -// return nil -// }) -// errors.Join(err, jq.Collect()) -type JobQueue struct { - wg sync.WaitGroup - errCh chan error -} - -func NewJobQueue(maxConcurrency uint) *JobQueue { - q := &JobQueue{ - errCh: make(chan error, maxConcurrency), - } - for i := 0; i < cap(q.errCh); i++ { - q.errCh <- nil - } - return q -} - -// Put runs a job in the background, returning any error from the previous job. -func (q *JobQueue) Put(job func() error) error { - err := <-q.errCh - q.wg.Add(1) - go func() { - defer q.wg.Done() - q.errCh <- job() - }() - return err -} - -// Collect waits for all jobs to finish, returning any errors. -func (q *JobQueue) Collect() error { - q.wg.Wait() - var err []error - for i := 0; i < cap(q.errCh); i++ { - err = append(err, <-q.errCh) - q.errCh <- nil - } - return errors.Join(err...) -} diff --git a/apps/cli-go/pkg/storage/analytics.go b/apps/cli-go/pkg/storage/analytics.go deleted file mode 100644 index 12f2abe013..0000000000 --- a/apps/cli-go/pkg/storage/analytics.go +++ /dev/null @@ -1,68 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "net/http" - "os" - - "github.com/supabase/cli/pkg/fetcher" -) - -type AnalyticsBucketResponse struct { - Id string `json:"id"` // "test" - Name string `json:"name"` // "test" - CreatedAt string `json:"created_at"` // "2023-10-13T17:48:58.491Z" - UpdatedAt string `json:"updated_at"` // "2023-10-13T17:48:58.491Z" -} - -type CreateAnalyticsBucketRequest struct { - BucketName string `json:"bucketName"` -} - -func (s *StorageAPI) UpsertAnalyticsBuckets(ctx context.Context, bucketConfig map[string]struct{}, filter ...func(string) bool) error { - resp, err := s.Send(ctx, http.MethodGet, "/storage/v1/iceberg/bucket", nil) - if err != nil { - return err - } - buckets, err := fetcher.ParseJSON[[]AnalyticsBucketResponse](resp.Body) - if err != nil { - return err - } - var toDelete []string - exists := make(map[string]struct{}, len(buckets)) - for _, b := range buckets { - exists[b.Name] = struct{}{} - if _, ok := bucketConfig[b.Name]; !ok { - toDelete = append(toDelete, b.Name) - } - } - for name := range bucketConfig { - if _, ok := exists[name]; ok { - fmt.Fprintln(os.Stderr, "Bucket already exists:", name) - continue - } - fmt.Fprintln(os.Stderr, "Creating analytics bucket:", name) - body := CreateAnalyticsBucketRequest{BucketName: name} - if resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/iceberg/bucket", body); err != nil { - return err - } else if err := resp.Body.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } -OUTER: - for _, name := range toDelete { - for _, keep := range filter { - if !keep(name) { - continue OUTER - } - } - fmt.Fprintln(os.Stderr, "Pruning analytics bucket:", name) - if resp, err := s.Send(ctx, http.MethodDelete, "/storage/v1/iceberg/bucket/"+name, nil); err != nil { - return err - } else if err := resp.Body.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } - return nil -} diff --git a/apps/cli-go/pkg/storage/api.go b/apps/cli-go/pkg/storage/api.go deleted file mode 100644 index a2af3588e3..0000000000 --- a/apps/cli-go/pkg/storage/api.go +++ /dev/null @@ -1,11 +0,0 @@ -package storage - -import "github.com/supabase/cli/pkg/fetcher" - -type StorageAPI struct { - *fetcher.Fetcher -} - -const PAGE_LIMIT = 100 - -const DELETE_OBJECTS_LIMIT = 1000 diff --git a/apps/cli-go/pkg/storage/batch.go b/apps/cli-go/pkg/storage/batch.go deleted file mode 100644 index bb6dad0b5d..0000000000 --- a/apps/cli-go/pkg/storage/batch.go +++ /dev/null @@ -1,131 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "io/fs" - "os" - "path" - "path/filepath" - - "github.com/go-errors/errors" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/queue" -) - -func (s *StorageAPI) UpsertBuckets(ctx context.Context, bucketConfig config.BucketConfig, filter ...func(string) bool) error { - buckets, err := s.ListBuckets(ctx) - if err != nil { - return err - } - exists := make(map[string]string, len(buckets)) - for _, b := range buckets { - exists[b.Name] = b.Id - } -OUTER: - for name, bucket := range bucketConfig { - // Update bucket properties if already exists - if bucketId, ok := exists[name]; ok { - for _, keep := range filter { - if !keep(bucketId) { - continue OUTER - } - } - fmt.Fprintln(os.Stderr, "Updating Storage bucket:", bucketId) - body := UpdateBucketRequest{ - Id: bucketId, - Public: bucket.Public, - FileSizeLimit: int64(bucket.FileSizeLimit), - AllowedMimeTypes: bucket.AllowedMimeTypes, - } - if _, err := s.UpdateBucket(ctx, body); err != nil { - return err - } - } else { - fmt.Fprintln(os.Stderr, "Creating Storage bucket:", name) - body := CreateBucketRequest{ - Name: name, - Public: bucket.Public, - FileSizeLimit: int64(bucket.FileSizeLimit), - AllowedMimeTypes: bucket.AllowedMimeTypes, - } - if _, err := s.CreateBucket(ctx, body); err != nil { - return err - } - } - } - return nil -} - -type UploadOptions struct { - MaxConcurrency uint - KeyPrefix string -} - -func isUploadableEntry(fsys fs.FS, filePath string, info fs.DirEntry) bool { - if info.Type().IsRegular() { - return true - } - if info.Type().IsDir() { - return false - } - if info.Type()&fs.ModeSymlink != 0 { - f, err := fsys.Open(filePath) - if err != nil { - fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) - return false - } - defer f.Close() - stat, err := f.Stat() - if err != nil || !stat.Mode().IsRegular() { - fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) - return false - } - return true - } - fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) - return false -} - -func (s *StorageAPI) UpsertObjects(ctx context.Context, bucketConfig config.BucketConfig, fsys fs.FS, opts ...func(*UploadOptions)) error { - uo := UploadOptions{MaxConcurrency: 5} - for _, apply := range opts { - apply(&uo) - } - jq := queue.NewJobQueue(uo.MaxConcurrency) - for name, bucket := range bucketConfig { - localPath := bucket.ObjectsPath - if len(localPath) == 0 { - continue - } - upload := func(filePath string, info fs.DirEntry, err error) error { - if err != nil { - return errors.New(err) - } - if !isUploadableEntry(fsys, filePath, info) { - return nil - } - dstPath := uo.KeyPrefix - relPath, err := filepath.Rel(localPath, filePath) - if err != nil { - return errors.Errorf("failed to resolve relative path: %w", err) - } else if relPath == "." { - // Copying single file - dstPath = path.Join(name, info.Name()) - } else { - dstPath = path.Join(name, filepath.ToSlash(relPath)) - } - fmt.Fprintln(os.Stderr, "Uploading:", filePath, "=>", dstPath) - job := func() error { - return s.UploadObject(ctx, dstPath, filePath, fsys, func(fo *FileOptions) { - fo.Overwrite = true - }) - } - return jq.Put(job) - } - if err := fs.WalkDir(fsys, localPath, upload); err != nil { - return err - } - } - return jq.Collect() -} diff --git a/apps/cli-go/pkg/storage/buckets.go b/apps/cli-go/pkg/storage/buckets.go deleted file mode 100644 index 1f4bf9cc83..0000000000 --- a/apps/cli-go/pkg/storage/buckets.go +++ /dev/null @@ -1,78 +0,0 @@ -package storage - -import ( - "context" - "net/http" - - "github.com/supabase/cli/pkg/fetcher" -) - -type BucketResponse struct { - Id string `json:"id"` // "test" - Name string `json:"name"` // "test" - Owner string `json:"owner"` // "" - Public bool `json:"public"` // true - FileSizeLimit *int `json:"file_size_limit"` // null - AllowedMimeTypes []string `json:"allowed_mime_types"` // null - CreatedAt string `json:"created_at"` // "2023-10-13T17:48:58.491Z" - UpdatedAt string `json:"updated_at"` // "2023-10-13T17:48:58.491Z" -} - -func (s *StorageAPI) ListBuckets(ctx context.Context) ([]BucketResponse, error) { - resp, err := s.Send(ctx, http.MethodGet, "/storage/v1/bucket", nil) - if err != nil { - return nil, err - } - return fetcher.ParseJSON[[]BucketResponse](resp.Body) -} - -type CreateBucketRequest struct { - Name string `json:"name"` // "string", - Id string `json:"id,omitempty"` // "string", - Public *bool `json:"public,omitempty"` // false, - FileSizeLimit int64 `json:"file_size_limit,omitempty"` // 0, - AllowedMimeTypes []string `json:"allowed_mime_types,omitempty"` // ["string"] -} - -type CreateBucketResponse struct { - Name string `json:"name"` -} - -func (s *StorageAPI) CreateBucket(ctx context.Context, body CreateBucketRequest) (CreateBucketResponse, error) { - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/bucket", body) - if err != nil { - return CreateBucketResponse{}, err - } - return fetcher.ParseJSON[CreateBucketResponse](resp.Body) -} - -type UpdateBucketRequest struct { - Id string `json:"-"` - Public *bool `json:"public,omitempty"` // false, - FileSizeLimit int64 `json:"file_size_limit,omitempty"` // 0, - AllowedMimeTypes []string `json:"allowed_mime_types,omitempty"` // ["string"] -} - -type UpdateBucketResponse struct { - Message string `json:"message"` -} - -func (s *StorageAPI) UpdateBucket(ctx context.Context, body UpdateBucketRequest) (UpdateBucketResponse, error) { - resp, err := s.Send(ctx, http.MethodPut, "/storage/v1/bucket/"+body.Id, body) - if err != nil { - return UpdateBucketResponse{}, err - } - return fetcher.ParseJSON[UpdateBucketResponse](resp.Body) -} - -type DeleteBucketResponse struct { - Message string `json:"message"` -} - -func (s *StorageAPI) DeleteBucket(ctx context.Context, bucketId string) (DeleteBucketResponse, error) { - resp, err := s.Send(ctx, http.MethodDelete, "/storage/v1/bucket/"+bucketId, nil) - if err != nil { - return DeleteBucketResponse{}, err - } - return fetcher.ParseJSON[DeleteBucketResponse](resp.Body) -} diff --git a/apps/cli-go/pkg/storage/objects.go b/apps/cli-go/pkg/storage/objects.go deleted file mode 100644 index f338a63b2a..0000000000 --- a/apps/cli-go/pkg/storage/objects.go +++ /dev/null @@ -1,219 +0,0 @@ -package storage - -import ( - "context" - "io" - "io/fs" - "mime" - "net/http" - "os" - "path" - "path/filepath" - "strings" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/pkg/fetcher" -) - -type ListObjectsQuery struct { - Prefix string `json:"prefix"` - Search string `json:"search,omitempty"` - Limit int `json:"limit,omitempty"` - Offset int `json:"offset,omitempty"` -} - -type ObjectResponse struct { - Name string `json:"name"` // "abstract.pdf" - Id *string `json:"id"` // "9b7f9f48-17a6-4ca8-b14a-39b0205a63e9" - UpdatedAt *string `json:"updated_at"` // "2023-10-13T18:08:22.068Z" - CreatedAt *string `json:"created_at"` // "2023-10-13T18:08:22.068Z" - LastAccessedAt *string `json:"last_accessed_at"` // "2023-10-13T18:08:22.068Z" - Metadata *ObjectMetadata `json:"metadata"` // null -} - -type ObjectMetadata struct { - ETag string `json:"eTag"` // "\"887ea9be3c68e6f2fca7fd2d7c77d8fe\"" - Size int `json:"size"` // 82702 - Mimetype string `json:"mimetype"` // "application/pdf" - CacheControl string `json:"cacheControl"` // "max-age=3600" - LastModified string `json:"lastModified"` // "2023-10-13T18:08:22.000Z" - ContentLength int `json:"contentLength"` // 82702 - HttpStatusCode int `json:"httpStatusCode"` // 200 -} - -func (s *StorageAPI) ListObjects(ctx context.Context, bucket, prefix string, page int) ([]ObjectResponse, error) { - dir, name := path.Split(prefix) - query := ListObjectsQuery{ - Prefix: dir, - Search: name, - Limit: PAGE_LIMIT, - Offset: PAGE_LIMIT * page, - } - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/object/list/"+bucket, query) - if err != nil { - return nil, err - } - return fetcher.ParseJSON[[]ObjectResponse](resp.Body) -} - -type FileOptions struct { - CacheControl string - ContentType string - Overwrite bool -} - -func ParseFileOptions(f fs.File, opts ...func(*FileOptions)) (*FileOptions, error) { - // Customise file options - fo := &FileOptions{} - for _, apply := range opts { - apply(fo) - } - // Use default value of storage-js: https://github.com/supabase/storage-js/blob/main/src/packages/StorageFileApi.ts#L22 - if len(fo.CacheControl) == 0 { - fo.CacheControl = "max-age=3600" - } - // Decode mimetype - if len(fo.ContentType) == 0 { - header := io.LimitReader(f, 512) - buf, err := io.ReadAll(header) - if err != nil { - return nil, errors.Errorf("failed to read file: %w", err) - } - fo.ContentType = http.DetectContentType(buf) - if s, ok := f.(io.Seeker); !ok { - return nil, errors.Errorf("file is not seekable") - } else if _, err = s.Seek(0, io.SeekStart); err != nil { - return nil, errors.Errorf("failed to seek file: %w", err) - } - } - return fo, nil -} - -func (s *StorageAPI) UploadObject(ctx context.Context, remotePath, localPath string, fsys fs.FS, opts ...func(*FileOptions)) error { - f, err := fsys.Open(localPath) - if err != nil { - return errors.Errorf("failed to open file: %w", err) - } - defer f.Close() - fo, err := ParseFileOptions(f, opts...) - if err != nil { - return err - } - // For text/plain content types, we try to determine a more specific type - // based on the file extension, as the initial detection might be too generic - if strings.Contains(fo.ContentType, "text/plain") { - if extensionType := mime.TypeByExtension(filepath.Ext(localPath)); extensionType != "" { - fo.ContentType = extensionType - } - } - return s.UploadObjectStream(ctx, remotePath, f, *fo) -} - -func (s *StorageAPI) UploadObjectStream(ctx context.Context, remotePath string, localFile io.Reader, fo FileOptions) error { - headers := func(req *http.Request) { - if len(fo.ContentType) > 0 { - req.Header.Add("Content-Type", fo.ContentType) - } - if len(fo.CacheControl) > 0 { - req.Header.Add("Cache-Control", fo.CacheControl) - } - if fo.Overwrite { - req.Header.Add("x-upsert", "true") - } - } - // Prepare request - remotePath = strings.TrimPrefix(remotePath, "/") - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/object/"+remotePath, io.NopCloser(localFile), headers) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -func (s *StorageAPI) DownloadObject(ctx context.Context, remotePath, localPath string, fsys afero.Fs) error { - f, err := fsys.OpenFile(localPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if err != nil { - return errors.Errorf("failed to create file: %w", err) - } - defer f.Close() - return s.DownloadObjectStream(ctx, remotePath, f) -} - -func (s *StorageAPI) DownloadObjectStream(ctx context.Context, remotePath string, localFile io.Writer) error { - remotePath = strings.TrimPrefix(remotePath, "/") - resp, err := s.Send(ctx, http.MethodGet, "/storage/v1/object/"+remotePath, nil) - if err != nil { - return err - } - defer resp.Body.Close() - _, err = io.Copy(localFile, resp.Body) - return err -} - -type MoveObjectRequest struct { - BucketId string `json:"bucketId"` - SourceKey string `json:"sourceKey"` - DestinationKey string `json:"destinationKey"` -} - -type MoveObjectResponse = DeleteBucketResponse - -func (s *StorageAPI) MoveObject(ctx context.Context, bucketId, srcPath, dstPath string) (MoveObjectResponse, error) { - body := MoveObjectRequest{ - BucketId: bucketId, - SourceKey: srcPath, - DestinationKey: dstPath, - } - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/object/move", body) - if err != nil { - return MoveObjectResponse{}, err - } - return fetcher.ParseJSON[MoveObjectResponse](resp.Body) -} - -type CopyObjectRequest = MoveObjectRequest - -type CopyObjectResponse struct { - Key string `json:"key"` -} - -func (s *StorageAPI) CopyObject(ctx context.Context, bucketId, srcPath, dstPath string) (CopyObjectResponse, error) { - body := CopyObjectRequest{ - BucketId: bucketId, - SourceKey: srcPath, - DestinationKey: dstPath, - } - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/object/copy", body) - if err != nil { - return CopyObjectResponse{}, err - } - return fetcher.ParseJSON[CopyObjectResponse](resp.Body) -} - -type DeleteObjectsRequest struct { - Prefixes []string `json:"prefixes"` -} - -type DeleteObjectsResponse struct { - BucketId string `json:"bucket_id"` // "private" - Owner string `json:"owner"` // "" - OwnerId string `json:"owner_id"` // "" - Version string `json:"version"` // "cf5c5c53-ee73-4806-84e3-7d92c954b436" - Name string `json:"name"` // "abstract.pdf" - Id string `json:"id"` // "9b7f9f48-17a6-4ca8-b14a-39b0205a63e9" - UpdatedAt string `json:"updated_at"` // "2023-10-13T18:08:22.068Z" - CreatedAt string `json:"created_at"` // "2023-10-13T18:08:22.068Z" - LastAccessedAt string `json:"last_accessed_at"` // "2023-10-13T18:08:22.068Z" - Metadata ObjectMetadata `json:"metadata"` // null -} - -func (s *StorageAPI) DeleteObjects(ctx context.Context, bucket string, prefixes []string) ([]DeleteObjectsResponse, error) { - body := DeleteObjectsRequest{Prefixes: prefixes} - resp, err := s.Send(ctx, http.MethodDelete, "/storage/v1/object/"+bucket, body) - if err != nil { - return nil, err - } - return fetcher.ParseJSON[[]DeleteObjectsResponse](resp.Body) -} diff --git a/apps/cli-go/pkg/storage/objects_test.go b/apps/cli-go/pkg/storage/objects_test.go deleted file mode 100644 index 8983495097..0000000000 --- a/apps/cli-go/pkg/storage/objects_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package storage - -import ( - "bytes" - "context" - "io" - "mime" - "net/http" - "os" - "path/filepath" - "testing" - fs "testing/fstest" - - "github.com/h2non/gock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/fetcher" -) - -var mockApi = StorageAPI{Fetcher: fetcher.NewFetcher( - "http://127.0.0.1", -)} - -func TestParseFileOptionsContentTypeDetection(t *testing.T) { - tests := []struct { - name string - content []byte - filename string - opts []func(*FileOptions) - wantMimeType string - wantCacheCtrl string - }{ - { - name: "detects PNG image", - content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // PNG header - filename: "test.image", - wantMimeType: "image/png", - wantCacheCtrl: "max-age=3600", - }, - { - name: "detects JavaScript file", - content: []byte("const hello = () => console.log('Hello, World!');"), - filename: "script.js", - wantMimeType: mime.TypeByExtension(".js"), - wantCacheCtrl: "max-age=3600", - }, - { - name: "detects CSS file", - content: []byte(".header { color: #333; font-size: 16px; }"), - filename: "styles.css", - wantMimeType: mime.TypeByExtension(".css"), - wantCacheCtrl: "max-age=3600", - }, - { - name: "detects SQL file", - content: []byte("SELECT * FROM users WHERE id = 1;"), - filename: "query.sql", - wantMimeType: mime.TypeByExtension(".sql"), - wantCacheCtrl: "max-age=3600", - }, - { - name: "use text/plain as fallback for unrecognized extensions", - content: []byte("const hello = () => console.log('Hello, World!');"), - filename: "main.nonexistent", - wantMimeType: "text/plain; charset=utf-8", - wantCacheCtrl: "max-age=3600", - }, - { - name: "respects custom content type", - content: []byte("const hello = () => console.log('Hello, World!');"), - filename: "custom.js", - wantMimeType: "application/custom", - wantCacheCtrl: "max-age=3600", - opts: []func(*FileOptions){func(fo *FileOptions) { fo.ContentType = "application/custom" }}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a temporary file with test content - fsys := fs.MapFS{tt.filename: &fs.MapFile{Data: tt.content}} - // Setup mock api - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Post("/storage/v1/object/"+tt.filename). - MatchHeader("Content-Type", tt.wantMimeType). - MatchHeader("Cache-Control", tt.wantCacheCtrl). - Reply(http.StatusOK) - // Parse options - err := mockApi.UploadObject(context.Background(), tt.filename, tt.filename, fsys, tt.opts...) - // Assert results - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - } -} - -func TestUpsertObjects(t *testing.T) { - t.Run("uploads regular files", func(t *testing.T) { - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Post("/storage/v1/object/uploads/pizza.jpg"). - Reply(http.StatusOK) - fsys := fs.MapFS{ - "seeds/pizza.jpg": &fs.MapFile{Data: []byte("image")}, - } - err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ - "uploads": {ObjectsPath: "seeds"}, - }, fsys) - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("uploads symlinked files", func(t *testing.T) { - tmpDir := t.TempDir() - fixturesDir := filepath.Join(tmpDir, "fixtures") - seedsDir := filepath.Join(tmpDir, "seeds") - require.NoError(t, os.MkdirAll(fixturesDir, 0o755)) - require.NoError(t, os.MkdirAll(seedsDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(fixturesDir, "pizza.jpg"), []byte("image"), 0o600)) - require.NoError(t, os.Symlink(filepath.Join(fixturesDir, "pizza.jpg"), filepath.Join(seedsDir, "pizza.jpg"))) - - defer gock.OffAll() - gock.New("http://127.0.0.1"). - Post("/storage/v1/object/uploads/pizza.jpg"). - Reply(http.StatusOK) - err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ - "uploads": {ObjectsPath: "seeds"}, - }, os.DirFS(tmpDir)) - assert.NoError(t, err) - assert.Empty(t, gock.Pending()) - assert.Empty(t, gock.GetUnmatchedRequests()) - }) - - t.Run("skips broken symlinks with warning", func(t *testing.T) { - tmpDir := t.TempDir() - seedsDir := filepath.Join(tmpDir, "seeds") - require.NoError(t, os.MkdirAll(seedsDir, 0o755)) - require.NoError(t, os.Symlink(filepath.Join(tmpDir, "missing.jpg"), filepath.Join(seedsDir, "pizza.jpg"))) - - stderr := captureStderr(t, func() { - err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ - "uploads": {ObjectsPath: "seeds"}, - }, os.DirFS(tmpDir)) - assert.NoError(t, err) - }) - assert.Contains(t, stderr, "Skipping non-regular file:") - assert.Contains(t, stderr, "seeds/pizza.jpg") - }) -} - -func captureStderr(t *testing.T, fn func()) string { - t.Helper() - reader, writer, err := os.Pipe() - require.NoError(t, err) - original := os.Stderr - os.Stderr = writer - t.Cleanup(func() { - os.Stderr = original - _ = reader.Close() - _ = writer.Close() - }) - done := make(chan struct{}) - var buf bytes.Buffer - go func() { - _, _ = io.Copy(&buf, reader) - close(done) - }() - fn() - closeErr := writer.Close() - os.Stderr = original - <-done - require.NoError(t, closeErr) - return buf.String() -} diff --git a/apps/cli-go/pkg/storage/vector.go b/apps/cli-go/pkg/storage/vector.go deleted file mode 100644 index 7218f7b28b..0000000000 --- a/apps/cli-go/pkg/storage/vector.go +++ /dev/null @@ -1,81 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "net/http" - "os" - - "github.com/supabase/cli/pkg/fetcher" -) - -type VectorBucket struct { - VectorBucketName string `json:"vectorBucketName"` - CreationTime uint64 `json:"creationTime"` -} - -type ListVectorBucketsResponse struct { - VectorBuckets []VectorBucket `json:"vectorBuckets"` -} - -type ListVectorBucketsRequest struct { - MaxResults uint64 `json:"maxResults,omitempty"` - NextToken string `json:"nextToken,omitempty"` - Prefix string `json:"prefix,omitempty"` -} - -type CreateVectorBucketRequest struct { - VectorBucketName string `json:"vectorBucketName"` -} - -type DeleteVectorBucketRequest struct { - VectorBucketName string `json:"vectorBucketName"` -} - -func (s *StorageAPI) UpsertVectorBuckets(ctx context.Context, bucketConfig map[string]struct{}, filter ...func(string) bool) error { - resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/vector/ListVectorBuckets", ListVectorBucketsRequest{}) - if err != nil { - return err - } - result, err := fetcher.ParseJSON[ListVectorBucketsResponse](resp.Body) - if err != nil { - return err - } - var toDelete []string - exists := make(map[string]struct{}, len(result.VectorBuckets)) - for _, b := range result.VectorBuckets { - exists[b.VectorBucketName] = struct{}{} - if _, ok := bucketConfig[b.VectorBucketName]; !ok { - toDelete = append(toDelete, b.VectorBucketName) - } - } - for name := range bucketConfig { - if _, ok := exists[name]; ok { - fmt.Fprintln(os.Stderr, "Bucket already exists:", name) - continue - } - fmt.Fprintln(os.Stderr, "Creating vector bucket:", name) - body := CreateVectorBucketRequest{VectorBucketName: name} - if resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/vector/CreateVectorBucket", body); err != nil { - return err - } else if err := resp.Body.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } -OUTER: - for _, name := range toDelete { - for _, keep := range filter { - if !keep(name) { - continue OUTER - } - } - fmt.Fprintln(os.Stderr, "Pruning vector bucket:", name) - body := DeleteVectorBucketRequest{VectorBucketName: name} - if resp, err := s.Send(ctx, http.MethodPost, "/storage/v1/vector/DeleteVectorBucket", body); err != nil { - return err - } else if err := resp.Body.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - } - return nil -} diff --git a/apps/cli-go/pkg/vault/batch.go b/apps/cli-go/pkg/vault/batch.go deleted file mode 100644 index 8f44bb797e..0000000000 --- a/apps/cli-go/pkg/vault/batch.go +++ /dev/null @@ -1,60 +0,0 @@ -package vault - -import ( - "context" - "fmt" - "os" - - "github.com/go-errors/errors" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/pgxv5" -) - -const ( - CREATE_VAULT_KV = "SELECT vault.create_secret($1, $2)" - READ_VAULT_KV = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)" - UPDATE_VAULT_KV = "SELECT vault.update_secret($1, $2)" -) - -type VaultTable struct { - Id string - Name string -} - -func UpsertVaultSecrets(ctx context.Context, secrets map[string]config.Secret, conn *pgx.Conn) error { - var keys []string - toInsert := map[string]string{} - for k, v := range secrets { - if len(v.SHA256) > 0 { - keys = append(keys, k) - toInsert[k] = v.Value - } - } - if len(keys) == 0 { - return nil - } - fmt.Fprintln(os.Stderr, "Updating vault secrets...") - rows, err := conn.Query(ctx, READ_VAULT_KV, keys) - if err != nil { - return errors.Errorf("failed to read vault: %w", err) - } - toUpdate, err := pgxv5.CollectRows[VaultTable](rows) - if err != nil { - return err - } - batch := pgx.Batch{} - for _, r := range toUpdate { - secret := secrets[r.Name] - batch.Queue(UPDATE_VAULT_KV, r.Id, secret.Value) - delete(toInsert, r.Name) - } - // Remaining secrets should be created - for k, v := range toInsert { - batch.Queue(CREATE_VAULT_KV, v, k) - } - if err := conn.SendBatch(ctx, &batch).Close(); err != nil { - return errors.Errorf("failed to update vault: %w", err) - } - return nil -} diff --git a/apps/cli-go/tools/dump_initial_schema.sh b/apps/cli-go/tools/dump_initial_schema.sh deleted file mode 100755 index 1e07e1fcc2..0000000000 --- a/apps/cli-go/tools/dump_initial_schema.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# pg_dumpall -d "$DB_URL" -gf global.sql --no-role-passwords - -# Explanation of special flags: -# -# --column-inserts include migration history, but not pgsodium key -pg_dump \ - --column-inserts \ - --exclude-table-data 'pgsodium.key' \ - -d 'postgres://supabase_admin:postgres@127.0.0.1:54322/postgres' \ -| sed 's/^CREATE SCHEMA /CREATE SCHEMA IF NOT EXISTS /' \ -| sed 's/^CREATE TABLE /CREATE TABLE IF NOT EXISTS /' \ -| sed 's/^CREATE SEQUENCE /CREATE SEQUENCE IF NOT EXISTS /' \ -| sed 's/^CREATE VIEW /CREATE OR REPLACE VIEW /' \ -| sed 's/^CREATE FUNCTION /CREATE OR REPLACE FUNCTION /' \ -| sed 's/^GRANT ALL ON FUNCTION graphql_public./-- GRANT ALL ON FUNCTION graphql_public./' - -echo "DROP EXTENSION pg_graphql; CREATE EXTENSION pg_graphql WITH SCHEMA graphql;" - -# Reset session config generated by pg_dump -echo "RESET ALL;" diff --git a/apps/cli-go/tools/listdep/main.go b/apps/cli-go/tools/listdep/main.go deleted file mode 100644 index db72b7d429..0000000000 --- a/apps/cli-go/tools/listdep/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "os" - "strings" - - "github.com/supabase/cli/pkg/config" -) - -func main() { - external := make([]string, 0) - for _, img := range config.Images.Services() { - if !strings.HasPrefix(img, "supabase/") || - strings.HasPrefix(img, "supabase/logflare") { - external = append(external, img) - } - } - - enc := json.NewEncoder(os.Stdout) - if err := enc.Encode(external); err != nil { - log.Fatal(err) - } -} diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 99426185fb..7f16660f9a 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -82,10 +82,83 @@ Every applicable command must preserve these invariants: `--output-format`, as established by `config diff` and `config pull`. 7. Preserve telemetry names, timing, identity, and payloads; see [Telemetry](#telemetry). +### Removing a command or flag + +Removal is breaking. First ship a stable deprecation warning on stderr that names the replacement +and the major that removes it, then remove on `next`. Replace a removed command with a tombstone +rather than deleting it; `db remote commit` is the reference: + +- Fail with `removedCommand(suggestion)` (or `removedFlag` for a flag) from + `command-internal/removed-command.ts`, wrapped in `withCommandTelemetry()` and + `withJsonErrorHandling`. Provide only `commandRuntimeLayer` and `telemetryStateLayer`. +- Keep the old flag and argument config so existing invocations reach the removal error, not a + parse error, and register the command with `Command.unlisted`. +- Delete the handler, its tests, and its docs pages, and drop it from `DOCS_EXCLUDED`. +- `SIDE_EFFECTS.md` documents exit 1, the removal message and suggestion, and the post-run + `telemetry.json` read and write under `SUPABASE_HOME`. +- Test every tombstone through the real parser with and without its old flags, asserting the + `RemovedSurfaceError` message and suggestion. + +## Config values + +Every config value resolves in one order for every command: flag > shell env > project `.env*` +(`supabase/` then the project root, by `SUPABASE_ENV`) > `config.toml` (a matched `[remotes.*]` +block over the base document) > default. Do not reorder it or special-case it per command. + +**Read a value** + +- Load once per command and pass the result down: `const configValues = yield* CliConfigValues`, + then `const resolvedConfig = yield* configValues.load({ workdir, projectRef })`. Do not reload a + resolved config a caller already holds. +- `resolvedConfig.get(CliConfigKeys.)` returns `{ value, origin }`; `origin.tier` names the + tier that won. +- For the whole document: `loaded` (what the project declares, every winner written in, no + defaults), `materialized.config` (`loaded` plus defaults and normalizers, what commands act on) + or `fileDeclared` (the config file alone, no flag or env overlay, for `config diff`/`pull`). + `envValues(names)` resolves `env(NAME)` references, shell before project `.env*`. +- Wrap writes that change config or `.temp` in `configValues.writeThrough(...)` so the memoised + resolved config is dropped. + +**Add a flag that sets a config value** + +1. List the name in `CLI_CONFIG_FLAGS` (`config/cli-config-key-annotations.ts`). +2. Declare it with `CliConfigKeys..flag({ name, description })` in the command config, never + a raw `Flag.*`. +3. Pipe the command config through `withCliConfigFlags` and provide `cliConfigValuesLayer` with + `Command.provide`. Read the value with `get`; never merge a flag into a value by hand. + +**Add a config key or env var** + +Add the field to `CliConfigSchema`; the registry key and its `SUPABASE_` env name +follow. Touch `config/cli-config-key-annotations.ts` only for a section gate, codec override, +secret, env-name override or exclusion. Never read a `SUPABASE_*` name anywhere else. + +**Variables that are not config keys** + +Names outside the registry (libpq `PG*`, Docker, proxy, `SUPABASE_YES`) come from +`resolvedConfig.projectEnvValues` (project `.env*` only; a shell-set name is not in it) or +`ambientEnvironment()` (the live process env). Importing `ambientEnvironment` is limited by +`oxlint` to the audited files listed in `.oxlintrc.json`. Never read a registry name through +either, and never read `process.env`, `Bun.env` or `globalThis.process` directly. + +**What fails the build** + +`code-structure.unit.test.ts` (registry env names as literals, direct env reads, a raw `Flag.*` for +a registry flag, `loadCliConfig`, `resolveCliConfigSubtree` or `loadCliProjectEnvironment` outside +`config/cli-config-*.ts` and `shared/config/cli-config-*.ts`), `oxlint` (`process.env`, `Bun.env`, +`ambientEnvironment` imports), `cli-config-contract.unit.test.ts` and +`cli-config-flag-ownership.unit.test.ts` (every registry key and `CLI_CONFIG_FLAGS` flag against +the command tree), and `tsc` (a command that reads config without `withCliConfigFlags`). + +**Exceptions** + +They live in [ADR 0032](../../docs/adr/0032-config-value-precedence.md) and nowhere else. Do not add a +per-command exception without updating the ADR and the guard's exemption list together. + ## Experimental feature registration -Resolve opt-in booleans with `command-internal/experimental-feature.ts`: environment `1`/`0` -overrides the project setting, and an unset or empty value uses the config. Invalid environment +Resolve opt-in booleans with `shared/config/experimental-feature.ts`: a shell boolean +(`1`, `t`, `T`, `TRUE`, `true`, `True` / `0`, `f`, `F`, `FALSE`, `false`, `False`; other casings such as `tRue` fail) overrides the project setting, and an unset or empty value uses the config. Invalid environment values are typed failures on applicable command paths. Disabled families are absent from the command tree, help, and completion; enabled help is marked experimental and stays out of stable generated command documentation. Environment opt-ins do not write project configuration, except @@ -100,15 +173,6 @@ compatibility decision in the PR. For the Compute transition, no local compatibi migrations are required. Compute error tags intentionally start new fingerprints; server-owned contracts remain unchanged. Update tests, generated schemas, and side-effect documentation. -## Go delegation - -- TypeScript owns CLI behavior. Consult `apps/cli-go` only for existing delegated - operations listed in [the delegation document](docs/go-cli-porting-status.md); do not expand delegation. -- Native replacements must preserve the public command, flags, output, side effects, - and exit behavior. Update the delegation document when removing a Go dependency. -- Emit command telemetry exactly once: bare proxies rely on Go telemetry; - instrumented TypeScript handlers suppress child telemetry. - ## Telemetry > An error’s tag is the PostHog `error_fingerprint` identity (`tag:`); preserve it when @@ -122,10 +186,14 @@ property shape (`flags`, `is_agent`, and `env_signals`), use canonical names and [event catalog](src/shared/telemetry/event-catalog.ts), and redact sensitive values. `safeFlags` may include only values that carry no user data. Keep the established safe project, -project-id, org-id, and version cases. When `--project-ref` also accepts branch names, whitelist -only values matching `PROJECT_REF_PATTERN`; never log a user-created branch name. Global boolean -and choice flags are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, -so a local path-valued `--output` remains redacted. Keep free-form user content redacted. +project-id, org-id, and version cases. For every command with a `hosted` or `selectable` target +policy, `withCommandTelemetry` reads the target flags from the invoked arguments, so they need no +entry in a command's `flags` or `safeFlags`: `--remote` (`true` when bare or linked, the project +ref when it is one, otherwise redacted), `--linked` and `--local` (their boolean), +`--project-ref`/`--project-id` (the value only when it matches `PROJECT_REF_PATTERN`, since it can +be a user-created branch name) and `--db-url` (always redacted). Global boolean and choice flags +are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, so a local +path-valued `--output` remains redacted. Keep free-form user content redacted. Do not remove, rename, or reshape custom events. Preserve login aliasing after token persistence, link project and organization identity after the link write (including branch-link metadata), @@ -161,6 +229,35 @@ text layer. Therefore stdout is payload-only for either machine flag. Do not cha layer to move spinners; that would change text output globally. Handlers check `--output` first and use established encoders before the `output.format` branch. +Plan-gated denials carry an optional `entitlement: { feature, upgrade_url }` on the json and +stream-json error object. `mapHttpError` attaches it from the Management API's +`entitlement_required` body, so a handler that rebuilds a mapped status error must forward it. + +## Targets and hosted consent + +Every leaf declares its target group and consent default with `withTargetPolicy`; the +classification test in `src/command-internal/target/target-policy.unit.test.ts` fails a leaf +without one. Declare the target flags only through `targetFlags` in +`src/command-internal/target/target-flags.ts`. + +- Database, storage, and seed commands call `resolveTarget`, then `resolveConnection`. Never pass a + default target: the policy owns the default, so a call site cannot fall back to local or linked. +- A command that binds `--password` passes `hasPasswordFlag: true` to `resolveConnection`. +- The linked `Target:` banner comes from `resolveTarget` and `resolveRef`; never build a linked + `ResolvedTarget` by hand. +- Hosted writes call `requireHostedMutationConsent` (or `requireTargetMutationConsent` when the + target may be local) after the plan and before the first persistent effect, and pass the + returned `HostedMutationConsent` to anything that needs proof of it. +- Never read stdin for consent. JSON, piped, and agent runs need `--yes` or a shell + `SUPABASE_YES`; `supabase/.env` and a cwd `.env*` file do not count. Write the action as an + imperative sentence-case phrase with no trailing period (`Delete project `). +- A `selectable` leaf with `consent: "none"` declares a `consentExemptReason`. +- Every leaf with consent has a `describeHostedConsent("", …)` test from + `tests/helpers/hosted-consent.ts`. + +See [ADR 0033](../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md) for the +target-default and consent rules. + ## Tests Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful diff --git a/apps/cli/README.md b/apps/cli/README.md index e379c971f5..5ad74ae94b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,18 +14,12 @@ This workspace contains: This workspace contains the stable, shipped `supabase` CLI. Earlier revisions carried two shells (a `legacy/` tree and an experimental `next/` tree); both are gone and `src/` is the single CLI tree. -For current migration/parity status, see: - -- [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) — the residual Go delegation surface -- [`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md) — TS-only flags and behavioral divergences from the old Go CLI - For the generated command/reference docs, see: -- [`docs/go-cli-reference.md`](./docs/go-cli-reference.md) - [`docs/supabase-home.md`](./docs/supabase-home.md) - [`../../packages/stack/docs/service-versioning.md`](../../packages/stack/docs/service-versioning.md) -The README is intentionally brief. Command details should live in the generated docs and the parity tracker above. +The README is intentionally brief. Command details should live in the generated docs above. ## Run From Source @@ -42,9 +36,7 @@ Examples: pnpm dev -- hello ``` -### CLI and the Go binary - -Phase 0 commands in the CLI proxy to the Go CLI binary. To run these commands from source you need `supabase` (the Go CLI) available on your PATH. +### Running from source For convenience, create a shell alias instead of using `pnpm dev` directly. For example in `.zshrc`: @@ -52,19 +44,6 @@ For convenience, create a shell alias instead of using `pnpm dev` directly. For alias supabase-dev="bun /absolute/path/to/dx-lab/apps/cli/src/main.ts" ``` -Then Phase 0 commands resolve the Go binary via PATH automatically: - -```sh -supabase-dev orgs list # proxied to supabase on PATH -supabase-dev login # native TypeScript -``` - -You can also point `SUPABASE_GO_BINARY` at a specific binary to skip the PATH lookup: - -```sh -export SUPABASE_GO_BINARY=/path/to/supabase -``` - ## Build There are two separate build paths depending on what you need. @@ -91,11 +70,10 @@ The shim resolves `SUPABASE_CLI_BINARY_OVERRIDE` (an absolute binary path) befor Used at release time to produce the compiled binaries that go into the platform-specific npm packages: ```sh -# CLI (TS SFE + Go binary for each platform) bun scripts/build.ts --version X.Y.Z ``` -For the CLI, this also cross-compiles the Go CLI binary from `apps/cli-go/` and places both binaries in `packages/cli-{platform}/bin/`. +This cross-compiles the CLI binary for each platform and places it in `packages/cli-{platform}/bin/`. See [`docs/binary-distribution.md`](./docs/binary-distribution.md) for a full explanation of the packaging model. @@ -165,11 +143,8 @@ Platform-specific packages live under: - `packages/cli-linux-*` - `packages/cli-windows-*` -Each platform package ships two binaries for the stable channel: +Each platform package ships a single compiled binary for the stable channel: - `bin/supabase` — the compiled TypeScript SFE (Bun single-file executable) -- `bin/supabase-go` — the compiled Go CLI binary, used by Phase 0 proxy commands - -The Go binary is compiled from `apps/cli-go/` at release time. Run `pnpm repos:install` after a fresh clone to make that source available. See [`docs/binary-distribution.md`](./docs/binary-distribution.md) for the full packaging model. diff --git a/apps/cli/bench/workloads.pr.json b/apps/cli/bench/workloads.pr.json new file mode 100644 index 0000000000..d599457f3d --- /dev/null +++ b/apps/cli/bench/workloads.pr.json @@ -0,0 +1,26 @@ +{ + "workloads": [ + { "id": "version", "args": ["--version"], "cwdSetup": "none" }, + { "id": "help", "args": ["--help"], "cwdSetup": "none" }, + { "id": "init", "args": ["init"], "cwdSetup": "none" }, + { "id": "migration-new", "args": ["migration", "new", "bench"], "cwdSetup": "init" }, + { + "id": "gen-types", + "args": ["gen", "types", "--lang", "typescript", "--project-id", "replayfixtureprojref"], + "cwdSetup": "none", + "scenario": "gen-types-generates-typescript-types-from-project" + }, + { + "id": "projects-list", + "args": ["projects", "list"], + "cwdSetup": "none", + "scenario": "projects-list-renders-project-list" + }, + { + "id": "functions-list", + "args": ["functions", "list", "--project-ref", "replayfixtureprojref"], + "cwdSetup": "none", + "scenario": "functions-list-renders-fixture-data-in-output" + } + ] +} diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md index 88cfd33d78..efc58d31b2 100644 --- a/apps/cli/docs/README.md +++ b/apps/cli/docs/README.md @@ -32,6 +32,20 @@ To publish by hand, run the same pipe the job runs: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--dry-run] ``` +The same job then publishes the config, project-config and `supabase.lock` JSON Schemas from +`apps/docs/public/schemas` with `scripts/publish-schemas.ts`. It copies each schema's current +(highest) `v.json` into `apps/www/public/schemas` on the `cli/schemas` branch in supabase/supabase, +serving it at `https://supabase.com/schemas//v.json`, opens a PR when the files differ, and +arms auto-merge, which squash-merges once a reviewer approves. Lower versions are frozen: the script +never writes them and refuses to run when supabase.com already serves a higher version than the +current one. Both publishers share the clone, branch, commit and PR flow in +`scripts/publish-pull-request.ts`. A failed publish of either pages Slack; rerun the "Publish to +supabase.com" job. + +```bash +bun scripts/publish-schemas.ts --version [--dry-run] +``` + ## Maintenance When adding or changing a command or flag, update the matching entries in diff --git a/apps/cli/docs/analytics.md b/apps/cli/docs/analytics.md index 225713f0fc..fe808b4684 100644 --- a/apps/cli/docs/analytics.md +++ b/apps/cli/docs/analytics.md @@ -49,6 +49,12 @@ installs the command span plus the per-invocation analytics context: That same context is then inherited by milestone events captured inside the command handler, which lets one CLI invocation share a single `command_run_id`. +The wrapper also rejects the removed global `--create-ticket` flag (with any value) before the +handler runs. The rejection happens inside the instrumented region, so it still emits +`cli_command_executed` with an `error_fingerprint` ending in `:removed_flag`. A command that fails +earlier, such as on a missing credential in its runtime layer, or that shows help, reports that +instead. + ## Event Model The primary analytics event is: @@ -79,9 +85,8 @@ and the KPI query semantics (strict recovery, repeat errors, internal/unknown bug rate) are documented there in `CliErrorActionabilityMetricDefinitions`. A `workflow` property is reserved in the catalog but not emitted yet. -Not every failure is classified: pure Go-proxy commands report through the Go -binary, which does not emit these fields, and events from CLI versions before -they existed never carry them. KPI queries therefore scope to +Not every failure is classified: events from CLI versions before these fields +existed never carry them. KPI queries therefore scope to `error_kind IS NOT NULL`, and the `classificationCoverage` metric definition reports the classified share of failures so the covered fraction is explicit rather than assumed. @@ -91,11 +96,11 @@ Local-runtime commands also carry properties that split error rates by runtime: - `stack_backend` (`legacy` or `stack`) on commands routed by `experimental.stack`; see [backend selection](stack-commands.md) - `stack_runtime` (`docker`, `podman`, or `native`): the runtime of the stack a stack-backend - command looked up, which for `--db-url` may be the project stack even when the URL targets + command looked up, which for `--remote=` may be the project stack even when the URL targets another database - `orioledb` (boolean) once the command resolves local database config, `true` when the project selects an OrioleDB image (`db.orioledb_version` on a 15/17 project). It describes local config - only; for linked or `--db-url` targets it says nothing about the remote database + only; for `--remote` targets it says nothing about the remote database Flag capture is intentionally conservative: `flags` records only flags changed on the command line, and redacts each value unless it is boolean, a choice flag, or listed in the command's @@ -120,6 +125,10 @@ The analytics layer attaches a base set of properties to every PostHog event: - `is_tty` - `is_ci` - `is_agent` +- `agent_name` (the detected agent, such as `claude`, `codex`, or `cursor_cli`; only when + `is_agent` is true) +- `is_eval` (true when `SUPABASE_EVAL` is `1` or `true`, so automated evaluation runs can be + excluded from usage metrics) - `env_signals` (allowlisted agent and terminal environment variables, when any are set) - `os` - `arch` diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index dce49f57a9..1c9705a51d 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -1,87 +1,52 @@ # Binary Distribution -This document explains how the Supabase CLI is packaged and distributed, covering the two-binary model used by the CLI. +This document explains how the Supabase CLI is packaged and distributed as a compiled binary per platform. ## Overview -The CLI is distributed as a set of platform-specific npm packages. Each platform package contains two binaries: +The CLI is distributed as a set of platform-specific npm packages. Each platform package contains one binary: ``` @supabase/cli-darwin-arm64/ └── bin/ - ├── supabase ← TypeScript CLI (Bun single-file executable) - └── supabase-go ← Go CLI binary (residual proxy commands only) + └── supabase ← TypeScript CLI (Bun single-file executable) ``` The base `supabase` package routes to the correct platform package via `src/shared/cli/bin.ts`, which resolves and `execFileSync`s the platform-specific `bin/supabase` binary. -## Why Two Binaries - -The CLI was built as a gradual TypeScript port of the Go CLI, moving each command through two phases: - -- **Phase 0** — The command is defined in the TS CLI tree but proxied to the Go binary at runtime via `GoProxy`. -- **Phase 1+** — The command is implemented natively in TypeScript. - -That port is complete (CLI-1970). `supabase-go` is the residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), the Go-deprecated `db branch`/`db remote changes` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. Two lifecycles apply here and must not be conflated: the **public commands** in that surface are retained (dropping them was ruled a breaking change — CLI-1964), while their **Go implementations** are slated for eventual native TS replacement, after which `supabase-go` stops shipping. Until the proxied surface is empty, the TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how. - ## Package Layout ``` packages/ - cli-darwin-arm64/bin/ supabase + supabase-go - cli-darwin-x64/bin/ supabase + supabase-go - cli-linux-arm64/bin/ supabase + supabase-go - cli-linux-x64/bin/ supabase + supabase-go - cli-linux-arm64-musl/bin/ supabase (musl TS binary only) - cli-linux-x64-musl/bin/ supabase (musl TS binary only) - cli-windows-arm64/bin/ supabase.exe + supabase-go.exe - cli-windows-x64/bin/ supabase.exe + supabase-go.exe + cli-darwin-arm64/bin/ supabase + cli-darwin-x64/bin/ supabase + cli-linux-arm64/bin/ supabase + cli-linux-x64/bin/ supabase + cli-linux-arm64-musl/bin/ supabase + cli-linux-x64-musl/bin/ supabase + cli-windows-arm64/bin/ supabase.exe + cli-windows-x64/bin/ supabase.exe ``` -The musl packages only carry the Bun TS binary (compiled for musl). The Go binary is statically linked (`CGO_ENABLED=0`), so the glibc Linux binary works on musl as well — it is installed alongside the musl TS binary by the Linux package managers (deb/rpm/apk) from the glibc build. - -## Runtime Resolution - -When a Phase 0 command runs, `go-proxy.layer.ts` resolves the Go binary in this order: - -1. **`SUPABASE_GO_BINARY` env var** — explicit override, takes priority. -2. **Co-located `supabase-go`** — looks next to `process.execPath`. Works in compiled SFE mode because the base shim uses `execFileSync`, making the TS SFE the main process with `process.execPath` pointing to itself. This is also how PATH-installed setups resolve: `supabase-go` ships next to the `supabase` shim. -3. **npm package resolution** — resolves `@supabase/cli-{platform}/bin/supabase-go`. Works when running from source with the platform packages installed. - -There is deliberately **no** `PATH` fallback (CLI-1488): the `supabase` shim itself is what's on `PATH`, so falling back to it would re-invoke the shim and fork-bomb. Resolution failure is a hard error with install guidance. - -## Source of the Go Binary - -The Go CLI source lives in `apps/cli-go/` and is managed via: - -```sh -pnpm repos:install -``` - -This must be run after a fresh clone before building a release. - ## Development Workflow -No build step is required to run the CLI from source, but the Go binary must be resolvable — easiest via `SUPABASE_GO_BINARY` (below) or an installed `@supabase/cli-` package. +No build step is required to run the CLI from source. -1. Build the Go binary once: `cd apps/cli-go && go build -o supabase-go .` -2. Create a shell alias to run the CLI from source. For example in `.zshrc`: +1. Create a shell alias to run the CLI from source. For example in `.zshrc`: ```sh alias supabase-dev="bun /path/to/dx-lab/apps/cli/src/main.ts" ``` -3. Point `SUPABASE_GO_BINARY` at the built binary and run commands: +2. Run commands directly: ```sh - export SUPABASE_GO_BINARY=/path/to/apps/cli-go/supabase-go - supabase-dev db branch list # proxied to the Go binary - supabase-dev login # native TypeScript implementation + supabase-dev login ``` ## Release Workflow -The `scripts/build.ts` script compiles both binaries for all target platforms: +The `scripts/build.ts` script compiles the binary for all target platforms: ```sh bun scripts/build.ts --version X.Y.Z @@ -90,51 +55,9 @@ bun scripts/build.ts --version X.Y.Z This: 1. Compiles the TS CLI to a Bun SFE for each platform → `packages/cli-{platform}/bin/supabase` -2. Cross-compiles the Go CLI (`CGO_ENABLED=0`) for each platform → `packages/cli-{platform}/bin/supabase-go` -3. Signs the macOS binaries (both `supabase` and `supabase-go`) before archiving, so every channel ships the signed bytes — see [release-process.md § Code signing (macOS)](./release-process.md#code-signing-macos) and [ADR 0014](../../../docs/adr/0014-macos-code-signing-and-notarization.md) -4. Bundles both binaries into the platform archives (`.tar.gz` / `.zip`) -5. Includes both binaries in the Linux package manager packages (deb/rpm/apk) - -### Go binary command surface - -`supabase-go` does not ship the full old Go CLI — only the fixed subset the TypeScript CLI still proxies to via `GoProxy`: - -- `db diff` — kept for `--use-pg-schema`, which wraps the in-process `stripe/pg-schema-diff` Go library with no TS/container equivalent (CLI-1960) -- `db branch create`, `db branch delete`, `db branch list`, `db branch switch` -- `db remote changes` -- `gen keys` — the public command is kept (its planned removal, CLI-1964, was cancelled as a breaking change); the Go implementation stays only until a native TS replacement lands -- `functions download` — kept for the hidden `--legacy-bundle` path (CLI-1963) - -That list is exhaustive: `GoProxy` is the only code path in the TypeScript CLI that spawns `supabase-go`. The one historical exception — the hidden pg-delta seam (`db schema declarative __catalog` + `db start`), spawned directly by the native `db schema declarative generate|sync` commands to provision shadow databases and export pg-delta catalogs — was ported to native TypeScript as part of CLI-1970 (`pgdelta.seam.layer.ts` now composes `setupShadowDatabase`/`legacyExportCatalogPgDelta`/`startLocalDatabase` in-process), and those two Go commands were deleted with it. - -Everything else the original Go CLI implemented was deleted outright from `apps/cli-go/` (CLI-1970), not just excluded from the shipped binary. The reachable set was computed with a `go list -deps -test` fixpoint from the trimmed `main` package, and everything outside it was removed: the main module's first-party package count went from 138 to 38 (100 packages / ~29.5k LOC across 321 files deleted), including every other command's `cmd/*.go` file, `internal/{inspect,storage,sso,login,link,init,bootstrap}`, `internal/migration/{squash,up,fetch}`, the Go docs generator (`docs/`), `examples/`, and `tools/{jsonschema,shared}`. Counting stdlib and third-party dependencies too, the full dependency closure shrank from 1078 to 959 packages. `pkg/` (a separate, independently tagged/published Go module for external consumers) and `tools/listdep` (used by `cli-go-mirror.yml`) were left untouched. - -For any command or package deleted by CLI-1970, the parity/provenance reference is the last commit with the full source intact: `7b469f5b3` — the same convention CLI-1966 established for `internal/start` (see below). - -### Binary size - -Measured on the CLI-1970 branch with the real release build (`build.ts`, `go build -trimpath -ldflags "-s -w"`, `CGO_ENABLED=0`): - -| Platform | `supabase-go` size | -| ------------- | -----------------: | -| darwin-arm64 | 40.1 MB | -| darwin-x64 | 42.6 MB | -| linux-arm64 | 39.1 MB | -| linux-x64 | 41.8 MB | -| windows-arm64 | 39.5 MB | -| windows-x64 | 42.8 MB | - -(musl packages ship the glibc `supabase-go` binary — see Package Layout above.) - -Progression across both trims: the original two-binary baseline was 97–103 MB per platform; CLI-1966's `internal/start` deletion cut it to roughly 47–52 MB; CLI-1970 brings it to 39.1–42.8 MB. - -The remaining size is dominated by dependencies the retained commands still need: `stripe/pg-schema-diff` (`db diff --use-pg-schema`), the Docker client (shadow-database provisioning for diff), pgx, the Management API client, cobra/viper, and sentry/posthog. - -Release archive sizes (TS `supabase` binary + `supabase-go` together): `.tar.gz` 37.9–52.9 MB, `.zip` (Windows) 50.0–53.0 MB, `.deb` 52.7–53.4 MB, `.rpm` 52.3–53.2 MB, `.apk` 52.8–54.1 MB. - -### Historical note: CLI-1966 (`internal/start` deletion) - -`apps/cli-go/internal/start` (Go's `supabase start` implementation) was the first package deleted outright rather than excluded from the build. Native TS `start` talks to Docker directly and never proxied to Go for it, and a repo-wide grep confirmed no other TS→Go delegation seam called into it either. `internal/start` alone had previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector). The last commit with the source intact is `a253ccba2` (full hash `a253ccba25c21356ccd33044c4474aecb77d1ae4`). +2. Signs the macOS binaries before archiving, so every channel ships the signed bytes — see [release-process.md § Code signing (macOS)](./release-process.md#code-signing-macos) and [ADR 0014](../../../docs/adr/0014-macos-code-signing-and-notarization.md) +3. Bundles the binary into the platform archives (`.tar.gz` / `.zip`) +4. Includes the binary in the Linux package manager packages (deb/rpm/apk) ## See Also diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md new file mode 100644 index 0000000000..e1f502897e --- /dev/null +++ b/apps/cli/docs/command-target.md @@ -0,0 +1,149 @@ +# Command target + +Every command that acts on a database or hosted project announces the target it resolved, once, before it acts. The same identity goes to machine consumers as the JSON `target` object. + +This page is the shared reference for how a command's target is chosen, which errors the selection raises, the deprecated selector flags, how a hosted write is confirmed, and what the banner and the JSON `target` carry. [ADR 0033](../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md) records why. The `SIDE_EFFECTS.md` of every `hosted`, `selectable` and `account` command opens with a `Target:` line that links here and lists only what is specific to that command: its own calls, files, exit codes and prompt, and any selector rule that differs from this page. A `local-only` command has no `Target:` line. + +## Groups + +The `Target:` line names the command's group and its consent default: `Target: , consent default `, or `Target: , no consent` when the command never asks for hosted-mutation consent. A document that covers several subcommands gives each one's default. + +| Group | Acts on | With no `--remote` | Selector flags | +| ------------ | ------------------------------- | ------------------ | ---------------------------------------------------------------------------- | +| `selectable` | a database or the Storage API | the local stack | `--remote`; deprecated `--local`, `--linked`, `--project-ref` and `--db-url` | +| `hosted` | a hosted project | the linked project | `--remote=`; deprecated `--project-ref` | +| `account` | the account or an organisation | no project | none | +| `local-only` | the local stack or the checkout | the local stack | none | + +The consent default is the answer a confirmation starts on. It is `No` for anything that replaces or deletes live state and for arbitrary SQL, migration, seed and storage writes, and `Yes` for create-only operations (`projects create`, `orgs create`, `branches create`, `domains create` and `bootstrap`). `no consent` means the command never asks for hosted-mutation consent because it only reads or writes local files. It can still ask a local question of its own, such as `config pull` before it rewrites `config.toml` or `migration fetch` before it replaces local migrations. + +## Choosing a target + +| You pass | Target | +| ------------------- | ------------------------------------------------------------------------------- | +| nothing | The local stack on a `selectable` command; the linked project on a `hosted` one | +| `--remote` | The linked project | +| `--remote=` | That project: 20 lowercase letters, never looked up as a branch | +| `--remote=` | That database: a `postgres://` or `postgresql://` URL | +| `--remote=` | A branch of the linked project, by name or UUID | + +- **The linked project** is `SUPABASE_PROJECT_ID`, else `supabase/.temp/project-ref`. With neither, an interactive terminal (stdin and stdout are TTYs and the output is text) gets the project picker (`GET /v1/projects`), and any other run fails with `ProjectRefNotLinkedError`. A malformed ref from either source fails with `InvalidProjectRefError`. `SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote: it is a ref source once `--remote` is passed, and enough for the `default; pass --remote` hint. The CLI never falls back between local and remote. +- **A branch** resolves with the lookup `supabase link` uses (`GET /v1/projects/{parent}/branches`, where `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project), and the command acts on that branch's own project. With no linked project a UUID is looked up with `GET /v1/branches/{id}`, and one the API can't look up fails as ``Branch "" not found. Run `supabase branches list` to see available branches.``, never as a schema error. +- **Hosted-only commands** take `--remote=` for another project, and a bare `--remote` changes nothing for them. `link` takes its target as an argument and has no `--remote`. `branches *` commands act on a parent project, so their `--remote` takes a ref, never a branch name. +- **A database URL** applies only where the remote side is Postgres. `storage`, `seed buckets` and hosted commands need a project, so they reject `--remote=` when the flags are parsed, as an invalid `--remote` value (`--remote takes a project ref or a branch name.`, or `--remote takes a project ref.` on `branches *`). The run exits 1 before anything else happens, and the URL is never echoed. A value with `://` that is not a `postgres://` or `postgresql://` URL, and a keyword connection string (`host=… user=…`), is rejected on every command and is never looked up as a branch. +- **`--remote=true|yes|on|1|y`** means a bare `--remote`. `--no-remote` and `--remote=false|no|off|0|n` fail with `--remote doesn't take false. Leave it out to .`, and an empty `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=; leave it out to .` `` is `target the local database`, `target the local Storage API` or, on a hosted command, `use the linked project`; `` is `` where the command takes a database URL, `` elsewhere and `` on `branches *`. Neither error prints the command's help. +- **A local stack that is down in a linked checkout.** A database command that defaults to the local stack and can't reach it adds `To use the linked project instead, pass --remote.` to its error's suggestion when the checkout is linked (`SUPABASE_PROJECT_ID` is set, or `supabase/.temp/project-ref` names a project). `gen types` adds it to its errors for a local database container that is missing or can't be inspected. + +## `--remote ` + +`--remote=` always parses as a value. A bare `--remote` followed by a word is rewritten to `--remote=` when the command takes no operands (a hidden one does not count), when the word is a database URL, or when the command already has more words than operands. + +The one ambiguous shape is a bare `--remote` directly followed by a word the command took as an operand. It is settled before anything runs, and the error offers both readings: + +- **A project-ref-shaped word** (20 lowercase letters) fails when the command still has room for operands, with no network call: pass `--remote=` for that project, or put `--remote` last to keep the word as an operand. It is read as `--remote=` only when the command takes no operands or its operands are already full. +- **Any other word** is checked against the branches of the linked project, when one is known. The run fails when the word names a branch, equals a branch name ignoring case, or is one edit from the name of a non-default branch of five or more characters; swapping two adjacent letters is one edit. The error offers `--remote=` for the branch, or the command's operands followed by `--remote` for the linked project. Otherwise the word runs as the operand. `--remote=` is separate: its "Did you mean" suggestion allows up to two edits. +- **A listing that fails** still fails the run before anything runs, and the error gives the cause: `HTTP `, the network error, or a missing or invalid access token. It never echoes the word, and its linked-project example reads `supabase --remote`. The suggestion follows the cause: a `401` says to log in again, or to fix `SUPABASE_ACCESS_TOKEN` when the token came from the environment; a `403` says to use a token that can read the project's branches; a `404` says the linked ref may be a preview branch, so link its parent project. +- **`branches *` commands** apply only the project-ref-shaped check: their `--remote` never names a branch, so any other word is the operand and nothing more is checked. + +## Selector errors + +These fail with exit 1 before the command does any work: + +| Condition | Error | +| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| more than one of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--project-id` | `TargetSelectorConflictError` | +| `--remote` given more than once with different values | `TargetSelectorConflictError` | +| `--project-ref` or `--project-id` with `--local`, `--db-url` or `--remote=` | `TargetProjectRefConflictError` | +| an empty `--db-url`, `--project-ref` or `--project-id` | `TargetEmptyValueError` | +| `--project-ref` or `--project-id` given a URL or connection string | `TargetInvalidValueError` | +| `--remote=` on a command with no database connection | a flag-parse error: an invalid `--remote` value | +| a branch name given to a `branches *` command | `TargetNeedsProjectError` | +| a bare `--remote` followed by a project-ref-shaped word, or by a word that may name a branch | `TargetRemoteAmbiguousError` | +| `--password` with `--remote=` or the local default | `TargetPasswordConflictError` | +| `--remote=` naming no branch of the linked project | `LinkBranchNotFoundError` | +| a branch name with no linked project to search | `LinkBranchNotLinkedError` | +| linked state whose ref is malformed, so there is no project to search | `LinkParentRefInvalidError` | +| a branch that has no project ref yet because it is still provisioning | `LinkBranchNotReadyError` | +| the branch lookup fails to reach the API or gets a non-200 status | `LinkBranchListNetworkError`, `LinkBranchListStatusError` | + +A selector conflict lists only the selector flags the command has: `Only one of --remote, --linked, --db-url and --local can be set; got …`, with the suggestion to use `--remote` or `--remote=` on its own. A bare `--remote` together with `--project-ref=` is accepted: the alias's value wins and its warning prints. A `false` switch counts as absent, so `--linked=false` selects nothing. A repeated `--remote` is one selection when every occurrence resolves to the same target, and a bare `--remote` is the linked project, so `--remote --remote` and `--remote= --remote=` run. When the occurrences differ the run fails before anything runs, with `--remote is given more than once with different values; pass it once.` and the suggestion to pass `--remote` once. + +`LinkBranchNotFoundError` names the parent project, lists the branches it has, offers the nearest name as "Did you mean" and, for a word of lowercase letters, or a 20-letter word with an uppercase letter (which is still looked up as a branch and gets the lowercase spelling as a suggestion), notes that a project ref is exactly 20 lowercase letters. A `404` from the branch listing adds that the linked ref may itself be a preview branch and says to link its parent project. + +## `--password` + +`db dump`, `db pull`, `db push`, `db schema declarative generate`, `migration list`, `migration repair` and `migration squash` take `--password`, which applies only when the target is a project: a bare `--remote`, a ref or a branch. Anything else fails before the command runs, with `TargetPasswordConflictError`: + +- `--remote=`: `--password can't be used with --remote=. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres` +- the local default: `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.`, followed by `To use the linked project instead, pass --remote.` in a linked checkout. + +A command's own `SIDE_EFFECTS.md` records where else its password can come from and when it is withheld from a target. + +## Deprecated selector flags + +`--linked`, `--project-ref`, `--db-url` and `--local` (and `gen types --project-id`) still work in v3 and are removed in v4, as is the `linked` value of `db diff --from` and `--to`, which `remote` replaces. Each goes through the same resolver as the `--remote` form it maps to and prints a warning on stderr in every output format, before the `Target:` line. The warning names only the replacement: + +| Flag | The warning says | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--linked` | `--linked is deprecated and will be removed in v4. Use --remote instead` | +| `--project-ref=` | `--project-ref is deprecated and will be removed in v4. Use --remote= instead`, or `Use --remote= instead` when the value is a branch name | +| `--db-url=` | `--db-url is deprecated and will be removed in v4. Use --remote= instead`, then `, or --remote= with an access token set` when the URL names a Supabase project | +| `--local` | `--local isn't needed (local is the default) and will be removed in v4` | + +A warning never repeats an operand, a flag value or a database URL, because a CI log would keep them, and it carries no example command. Two values have their own wording: a branch whose name is a boolean literal (`true`, `false`, `yes`, `no`, `on`, `off`, `y`, `n`, `1`, `0`) is reached with `--remote=`, and a keyword `--db-url` connection string is converted to a `postgresql://` URL for `--remote=`. + +`db diff --from`/`--to` accepts the same flags and warns the same way, but names the `--from`/`--to` value that replaces each: `Use --from=remote or --to=remote instead` for `--linked`, `Use --from= or --to= instead` for `--project-ref` (`` for a branch name) and `Use --from= or --to= instead` for `--db-url`. `--project-ref` also points a `remote` side, or the deprecated `linked`, at the project it names, and fails when neither side is `remote`; `--linked`, `--db-url` and `--local` change nothing. `--remote` stays an error there. + +## Hosted-mutation consent + +Every persistent mutation of a hosted resource asks for consent first, including a write through a non-local `--remote=`. A loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local. + +- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count. The last `--yes` on the command line wins, and `SUPABASE_YES` is read only when `--yes` never appears, so `--yes=false --yes` confirms and `--yes --yes=false` declines. A bare `--yes` followed by `false`, `no`, `off`, `0` or `n` reads as false, as the flag parser does. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: on `. +- **Prompt.** Only text output on an interactive stdout with a TTY stdin, with no agent driving, is asked. The question starts on the command's consent default. +- **Refusal.** JSON, stream-json, `-o json|yaml|toml|env`, agent and non-TTY runs fail with `HostedMutationConsentRequiredError` unless `--yes` is set. The error names the action and target, says why the run can't ask and tells an agent to ask the user before passing `--yes`. Nothing is changed. +- **Decline.** A declined prompt fails with `HostedMutationDeclinedError` (`context canceled`), exit 1, and nothing is changed. +- **One question.** A command asks once, after the plan is known and before the first persistent effect, and lists everything it will do. +- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError`. Its suggestion is `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip --experimental --yes --remote=`, and `` is always the project whose bans blocked the connection, which after `db pull --remote=` is not the linked project. This applies to reads too. +- **Reads never prompt.** A command marked `no consent` never asks for hosted-mutation consent, on any target, and `--yes` and `SUPABASE_YES` have no effect on it. `db query --read-only` is a read and skips consent too. + +## Banner + +`Target: ` on stderr in every output format, printed once per distinct text. It is best effort: it adds no API call and no auth requirement, and it never prints credentials. + +| Target | Text | +| ------------------------------------- | ----------------------------------------------------------------------------------- | +| Local stack | `local stack (:)` | +| Local stack, nothing selected, linked | `local stack (:) · default; pass --remote for ()` | +| `--remote=` | `custom Postgres (:)`; the connection string's credentials never appear | +| Linked project the cache names | `project () · production` | +| Linked project nothing vouches for | `project `, or `project ()` when the project picker named it | +| Ref other than the linked project | `project (not the linked project)` | +| Branch found by name or UUID | `project () · branch ()` | +| The project's default branch | `project () · production` | +| Branch UUID, no linked project | `project `: the lookup does not say whether it is the default branch | + +On a `hosted` command the banner is the notice that the command is talking to a remote project, and it names the linked project the command defaults to. The `default; pass --remote` hint appears only when a project is linked, by `SUPABASE_PROJECT_ID` or the link file, and no `--remote` was passed; a bare `db push`, `db pull` or `migration fetch` in a linked checkout runs locally and prints it. + +Identity comes from `supabase/.temp/linked-project.json`, with no API call. A cache that names the resolved ref itself marks a production project. The cache can be stale or missing, so it never proves a branch: "branch" is claimed only on positive evidence, which is a branch lookup by name or UUID or a live probe. A branch's parent shows as its bare ref when the cache doesn't know its name. + +## JSON `target` + +`json` and `stream-json` results carry `target`, tagged by `kind`: + +| `kind` | Fields | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local` | `host`, `port` | +| `db-url` | `host`, `port` | +| `linked` | `project_ref`, `environment` (`production`, `branch` or `unverified`), optional `project_name`, optional `parent_project_ref` (a branch's parent project) | + +`environment` is `production` when the linked-project cache names the resolved ref or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, and `unverified` otherwise, including a branch UUID looked up with no linked project. A failed identity check is `unverified`, never `branch`. + +The first announced target becomes the run's `target`, so a command that announces two (`db diff --from=remote --to=`) prints both and reports the first. `db diff --from`/`--to` announces every database endpoint in resolution order, the local stack and a database URL included, so `--from local` reports `{"kind":"local","host":"127.0.0.1","port":54322}`; `migrations` announces nothing, and the same target on both sides prints one line. It is merged into object results, the `stream-json` result event and the machine error envelope. Arrays, `--output {json,yaml,toml,env}` payloads and generated artifacts such as `gen types` output are unchanged. A command that owns extra `target` fields (for example `branch`, `local_scope`, or `config push`'s `environment`) adds them in front of the announced identity's, and its own values win on a clash. + +The schema is `CommandTargetJson` in `src/shared/output/command-target.service.ts`; every target is encoded through it before it reaches output. + +## Help and docs + +A group's help labels each subcommand `[local or remote]` (`selectable`), `[remote only]` (`hosted`) or `[local only]`; `account` and removed commands carry no label. A command's own help has a `TARGET` section above `USAGE`, and its `--remote` row reads `--remote[=]` where the command takes a database URL, `--remote[=]` where it doesn't, and `--remote[=]` on `branches *`. JSON help gives the `remote` flag `value` and `valueOptional` fields in place of the bracketed name. + +Docs and examples put `--remote` last, in the `=` form when it has a value (`db pull add_profiles --remote`, `db push --remote=`), so it cannot be read as anything else. Shell completion never offers a space-separated value for `--remote` on a command that takes operands. diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f69581da9b..ae8b3a4395 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -6,8 +6,9 @@ configuration may change incompatibly while experimental. The family is opt in. `SUPABASE_EXPERIMENTAL_COMPUTE=1` or by setting `compute = true` under `[experimental]` in `supabase/config.toml`. -The environment variable accepts `1` to enable and `0` to disable. When it is -unset or empty, the config file is used; any other non-empty value reports an +The environment variable accepts a boolean (`1`, `t`, `T`, `TRUE`, `true`, `True` / `0`, `f`, `F`, `FALSE`, `false`, `False`; +other casings such as `tRue` fail) to enable or disable the family. When it is unset or empty, the config file is used; any +non-boolean value reports an invalid feature-flag value on Compute paths (including their completion), root help, or root-level completion before a subcommand is chosen. Unrelated commands do not resolve the Compute flag. Unreadable or malformed @@ -46,3 +47,9 @@ settings into an ignored file. To deploy a source directory without a Compute configuration entry, `compute push` requires an explicit `--exposure public` or `--exposure private`. Record the entry in the authoritative config to persist its deployment settings. + +`compute push` and `compute delete` change the remote project, so both ask for +confirmation first, and both default to no. `compute delete` asks for the +compute's name to be typed back. Pass `--yes`, or set `SUPABASE_YES` in your +shell, to confirm in scripts and CI; without it, a run that cannot prompt fails +before changing anything. diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md deleted file mode 100644 index 54ef768b90..0000000000 --- a/apps/cli/docs/go-cli-divergences.md +++ /dev/null @@ -1,255 +0,0 @@ -# Go CLI Divergences - -**Frozen historical ledger** of deliberate TypeScript divergences from the old Go CLI -(pre-`7b469f5b3`) on the CLI: TS-only commands, flags, and behavior with no Go -counterpart. The TypeScript CLI is now the source of truth, so this ledger no longer accumulates -entries — new flags, commands, and behavioral changes are simply new CLI behavior, documented -through help text, tests, and each command's `SIDE_EFFECTS.md`. This document exists to answer -support and migration questions about why the TS CLI does something the old Go CLI didn't — it is -not a compatibility promise. - -## TS-only Commands - -These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. - -| TS command | TS path | Notes | -| ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | - -## Flag divergences from the Go reference - -- `db diff`, `db pull`, and `db schema declarative generate`/`sync` have a TS-only - `--strict-coverage` flag (no Go equivalent). It applies whenever the bundled - pg-delta engine runs (always for the declarative commands; for `db diff` and - migration-style `db pull` when pg-delta is selected via - `[experimental.pgdelta] enabled = true`, `--use-pg-delta`, or - `--diff-engine pg-delta`): coverage gaps that the engine reports — statements - it skipped or objects it could not represent — normally surface as warnings, - and `--strict-coverage` promotes them to hard failures. Selecting migra (the - `db diff` / migration-style `db pull` default, or explicitly via `--use-migra` - / `--diff-engine migra`) accepts the flag but has no effect, since migra does - not emit coverage diagnostics. Default behavior (omitted flag) matches Go. -- `db push` has a TS-only `--skip-vault` flag. It applies migrations without - resolving or updating `[db.vault]` secrets; default behavior still matches Go. -- Every command that resolves a linked project ref for its own database - connection has a TS-only `--project-ref` flag (no Go equivalent on any - user-facing command — only the `SUPABASE_PROJECT_ID` env var could override - the linked ref; the Go registrations are a hidden, non-user-facing seam, - `db declarative __catalog --project-ref`, `cmd/pgdelta_catalog.go:44`, and the - deprecated Go-delegated `db remote changes`, which receives the linked ref this - way in stack mode). This - covers: `db push`, `db pull`, `db diff`, `db dump`, `db reset`, `db lint`, - `db advisors`, `db query`; `migration list`/`up`/`down`/`repair`/`fetch`/`squash`; - `seed buckets`; `storage ls`/`cp`/`mv`/`rm`; every `inspect db` subcommand and - `inspect report`; and `test db` (and its hidden `db test` alias, which shares - `test db`'s flag config verbatim). It feeds - `RemoteTarget.load`, keeping Go's precedence (flag > - `SUPABASE_PROJECT_ID` > `supabase/.temp/project-ref`) and taking effect only on - the linked path. It shares ONLY that ref-resolution precedence with - `SUPABASE_PROJECT_ID` — unlike the env var, it does not affect the local - container id or the pg-delta project id, and it does not imply `--linked`: - passing it alongside `--local`/`--db-url` (or, for `db diff`, in explicit mode - without `--from`/`--to linked`) is a hard error rather than a silently ignored - flag, since Go's env var going unused on a non-linked target has no TS-only - flag counterpart to accidentally discard. Default behavior (omitted flag) - matches Go exactly. -- A hosted ref from `SUPABASE_PROJECT_ID` that differs from the linked ref gets - its pooler URL from the Management API, like a `--project-ref` flag does. Go - reuses the saved `supabase/.temp/pooler-url` whichever ref the env var names. - The saved pooler URL, parent project, and branch name apply only when the - resolved ref is the linked one. -- `projects api-keys` has a TS-only `--reveal` flag (no Go equivalent). It sends - `reveal=true` so the Management API returns the full secret keys (`sb_secret_...`) in - full instead of redacting them, addressing issue #4775. Default behavior (omitted flag) - matches Go exactly. -- `projects create` has a TS-only `--high-availability` flag (no Go equivalent). It sets - `high_availability` in the create request body. Default behavior (omitted flag) matches - Go exactly. -- `projects create` has TS-only `--release-channel` and `--postgres-engine` flags (no Go - equivalent). They set `release_channel` / `postgres_engine` in the create request body — - fields the upstream Management API OpenAPI spec deliberately hides even though - `POST /v1/projects` accepts them (restored via `packages/api/scripts/openapi-overrides.json`, - CLI-2180). Both flags are hidden and gated behind `--experimental` (or `SUPABASE_EXPERIMENTAL`) - until PROD-548 exposes them officially; omitting them matches Go exactly. -- `db schema declarative generate` has a TS-only `--output-dir ` flag (no Go equivalent, - no short alias). It writes the generated declarative tree to the given directory for this - invocation only, without changing the configured `declarative_schema_path` — the staging step - of the legacy-tree upgrade recipe printed by the sync/generate compatibility gates. The name - deliberately avoids `--output`/`-o`, which the root command reserves for the global - machine-format flag; a leaf string flag would shadow it and turn `generate -o json` into a - write to a directory named `json`. Default behavior (omitted flag) matches Go. -- `link` has a TS-only `[ref-or-branch]` positional argument (no Go equivalent), and its - `--project-ref` flag additionally accepts a branch name. A value matching the 20-lowercase-letter - project ref shape is always treated as a ref; any other non-empty value is resolved to its - parent-project's branch project ref via the Management API before linking proceeds exactly as - today (CLI-2167). On the 404 (branch) link path, `link` also best-effort maintains - `linked-project.json`'s PARENT evidence (PR #6168 review, TS-only, no Go equivalent — Go never - writes this cache for a branch ref at all): a name/UUID-resolved branch link persists its known - parent ref (a ref-only record when no richer cache exists yet); a raw ref-shaped branch link - whose existing cache names a different project best-effort correlates the two via one extra - `listAllBranches` call and deletes the cache on EVERY unverified result — a verified mismatch, - a timeout, or any transport/status/decode failure (fail-safe: an unverifiable divergent cache is - untrustworthy). Both are best-effort and never affect `link`'s own outcome — see - `link/SIDE_EFFECTS.md`. -- `link` has a TS-only `--frozen` flag (no Go equivalent) that leaves `supabase/supabase.lock` - untouched in stack mode; it is a no-op in legacy mode, which has no lock. - -## Behavioral divergences from the Go reference - -- `db schema declarative generate`/`sync` default declarative directory is `supabase/schemas`; - the old Go CLI reference (pre-`7b469f5b3`) used `supabase/database`. The move aligns the - default with the product-wide declarative-schemas convention. To keep the upgrade visible, - both commands print a TS-only warning when `declarative_schema_path` is unset, the new - default directory is empty, and the former `supabase/database` default still contains `.sql` - files or an export manifest — telling the user to set - `declarative_schema_path = "./database"` or move the tree. The warning never changes - behavior or exit codes; a non-interactive sync still fails with Go's - "no declarative schema found" message. Inside that directory the bundled pg-delta - engine writes one directory per schema at the root — `supabase/schemas/public/tables/x.sql` — - with cluster-level objects under a reserved `supabase/schemas/_cluster/`. - Structured export is TypeScript-only; the Go binary no longer ships a - pg-delta dump path. -- Local `pg_net` presence now converges with `[experimental.webhooks]` instead of being - installed unconditionally: `db-webhook.sql` no longer creates the extension at container - init, `supabase start`/`db start` install it (with grants reapplied via the - `issue_pg_net_access` event trigger) only when webhooks are enabled or migration history - contains a `create extension … pg_net`, and an existing-volume `start` with webhooks - disabled drops a `pg_net` that migration history does not own. Known accepted edge: `pg_net` - installed OUTSIDE migrations (for example via local Studio's SQL editor or extension toggle) - is invisible to the migration-ownership heuristic and is dropped on the next `start`; a - tracked dependency on `net.*` (PG14+ `BEGIN ATOMIC` functions) makes that non-`CASCADE` drop - — and therefore `start` — fail. Workaround in both cases: enable `[experimental.webhooks]` - or declare the extension in a migration. Documented rather than special-cased. -- `test db` (and its `db test` alias) exits `1` when `pg_prove` ran no tests (CLI-2194, #6206). - `pg_prove` prints `Result: NOTESTS` and still exits `0` for an empty run, and Go returns that - code verbatim (`internal/db/test/test.go` → `DockerRunOnceWithConfig`), so a typo'd path, an - empty tests directory, or a bind the daemon resolved against a different filesystem than the - CLI's (a sibling-container Docker socket) all reported a green build that ran zero tests. The - TAP stream on stdout is unchanged; the diagnostic goes to stderr like every other failure. -- SQL file runners on a stepped-down session re-assert `SET SESSION ROLE postgres` - immediately after each top-level role revert (`RESET ROLE`, the generic-`SET` spellings - such as `SET ROLE [TO|=] NONE|DEFAULT` and a case-sensitively quoted `'none'`, - `RESET SESSION AUTHORIZATION`, `SET SESSION AUTHORIZATION DEFAULT`, `DISCARD ALL`), - at the end of each file, and before the migration history insert and the `seed_files` - upsert — so the whole file (including a post-reset `granted by current_user` cleanup), - every CLI-owned ledger write, and every subsequent file run as `postgres`, matching a - password session for `current_user` and privilege checks (CLI-2205, #6236). - `session_user` remains the login role and `current_setting('role')` reads `postgres` - rather than `none`, so a file keying on `session_user` still diverges. A stepped-down - session is any remote connection authenticating as `cli_login_*` (the passwordless - linked path) or `supabase_admin`, which steps down with a session-level - `SET SESSION ROLE postgres`; a file's own `RESET ROLE` reverted it to the login role, - so the appended history insert failed with SQLSTATE 42501 and any later file ran as - the login role. The old Go CLI had the same defect; on a `supabase_admin` `--db-url`, - `RESET ROLE` consequently no longer re-escalates to superuser mid-file. - Statement-level re-assertion is used because a connection-time `role=postgres` default - cannot be guaranteed through the pooler. Injected restores never shift - `At statement: N` and are never recorded in the history row. Residual: a role revert - issued through dynamic SQL, or a spelling outside the list above (for example - `SET LOCAL ROLE NONE` — deliberately unmatched, a session-scoped restore would - override its transaction scope — or the `session_authorization` GUC spellings), is - invisible to the lexical check, so statements after it run as the login role until - the next restore point; the end-of-file restore still protects every CLI-owned write. - (`RESET ALL` needs no entry — `role` carries `GUC_NO_RESET_ALL`.) -- `functions serve` per-function env discovery (CLI-2184, #6179): without `--env-file`, each - `supabase/functions//.env` overrides matching values from the shared - `supabase/functions/.env` for that Function only; an explicit `--env-file` remains the - highest-priority source and disables both automatic reads. TS-only feature — the old Go - command read only the shared fallback. -- `functions deploy`/`functions serve` import-map resolution follows the import-maps spec - (implemented by Deno): a key matches exactly, or as a prefix only when it ends with `/`. The - old Go walker prefix-matched every key (`pkg/function/deno.go:150-155`, still in-tree), which - fabricated paths the runtime could never resolve — the ENOTDIR crash family fixed in - PR #6164 (CLI-2179). Intentional divergence: the spec behavior is the correct one. -- `config push`/`start` auth email `content_path` resolution: every relative - `[auth.email.template.*]` AND `[auth.email.notification.*]` path resolves from the discovered - project root, with notifications additionally falling back to the legacy `supabase/`-relative - location when the root-resolved file is missing. Go resolves notifications from `supabase/` only - (`(*baseConfig).resolve`'s own `// FIXME`-flagged asymmetry). Config validation, `config push` - content loading, and Kong's template mount builder all share one resolver - (`resolveNotificationContentPath`), so every consumer reads the SAME file — the drift the - asymmetry caused (validated against `/supabase/...`, mounted from `/...`) is gone. - The `init` scaffold ejects the root-relative form, which is incompatible with Go if uncommented - (#6159/#6160). -- `db remote changes --password

`: since CLI-1970, an explicit - `--password` beats the `SUPABASE_DB_PASSWORD` env var. Before the trim, Go's - package-wide "last `viper.BindPFlag("DB_PASSWORD", …)` wins" behavior bound - the key to `projects create --db-password` (lexically last `cmd/*.go` file), - so `db remote`'s own `--password` flag was never the bound instance and env - silently won over it — a latent bug. With `projects.go` deleted, the bind - lands on `db remote`'s persistent `--password` and flag-beats-env applies as - intended. Accepted (not restored) in the CLI-1970 parity audit. `db remote -commit` is now native `db pull`, so it uses pull's flag-then-env-then-dotenv - password order. `db pull` keeps that precedence unchanged. -- `branches {list,create,get,update,delete,pause,unpause,disable}` resolve their project ref - through a PARENT-scoped chain instead of plain `--project-ref` flag/env/file resolution: an - explicit `--project-ref` still wins outright, but the fallback is env `SUPABASE_PROJECT_ID` → - `supabase/.temp/linked-project.json`'s `ref` → `supabase/.temp/project-ref`, first ref-shaped - candidate wins. This is a direct consequence of the `link` branch-name divergence above: after - `supabase link `, `project-ref` holds the branch's own ref, and the Management API - returns 403 for a branch ref on every branches-management endpoint. No-op when linked to a real - (non-branch) project — the cache and the file hold the same ref — so this only changes behavior - in the previously-403ing branch-linked state (CLI-2167 follow-up, no Go equivalent). -- `branches list`'s pretty table (not `-o json|yaml|toml`, not `--output-format json|stream-json`) - marks the row matching the CURRENTLY linked ref with a ` (active)` NAME cell. TS-only - QoL, no Go equivalent (CLI-2167 follow-up). -- `status` prints the current linked project/branch as a "Linked Project:" block on stdout in - human text mode (Neon-style — `Org:`/`Project:`/`Branch:` lines, each omitted when unknown), - before any daemon/stack work begins, and folds the same linked state into its machine-readable - outputs — additive `linked_project: {...} | null` (with `org_slug`/`org_id`) in the TS - `--output-format json`/`stream-json` payload, and additive `linked_project_ref`/ - `linked_project_name`/`linked_org_slug`/`linked_org_id`/`linked_branch`/ - `linked_parent_project_ref` keys (absent entirely when not linked) appended after the existing - keys in `-o env|json|yaml|toml`. A confirmed branch-linked state (from `linked-project.json`) - keeps showing the parent/org fields even when the branch-name lookup itself degrades (no - token, offline, API error) — only the branch's own name is ever missing, so the user always - sees they're on a branch. The Management API client for that lookup is acquired lazily - (`CommandPlatformApiFactory`, not the eager `CommandPlatformApi`) so `status` stays fully - functional offline/token-less. Intent: let an agent driving `status` discover which - project/branch it's on without a separate `link`/`branches` call. Read-only, never affects - `status`'s exit code, and never alters any of its existing failure behavior — a Docker/daemon - connection failure still fails exactly as today, with the linked block already printed above it - in text mode (CLI-2167 follow-up, no Go equivalent). The same `linked_project` object is also - carried on the `--output-format json`/`stream-json` FAILURE envelope (top-level, next to - `_tag`/`error` or `type`/`error`/`timestamp`) — the agent-discovery use case matters most when - `status` fails to reach a stopped stack — via a new opt-in, shared mechanism - (`shared/output/machine-error-context.service.ts`'s `MachineErrorContext`, read by - `jsonOutputLayer`/`streamJsonOutputLayer`'s `fail`); `-o env|json|yaml|toml`'s failure output is - deliberately unchanged (still no payload, matching Go). See `status/SIDE_EFFECTS.md`. -- `projects list`'s `LINKED` marker (the `linked` boolean, rendered as the pretty table's `●` - bullet) now falls back to the PARENT chain when the linked ref matches no row exactly — the same - scenario as above, since a branch-linked ref never matches a real project row. **This changes - the `linked` field in the `-o json|yaml|toml` Go-struct payloads too**: in the branch-linked - state it was previously `false` on every row; it can now be `true` on the parent project's row. - The truthful fix IS the behavior change (CLI-2167 follow-up, no Go equivalent). -- `services` warns on a malformed linked project ref (matching Go's - `flags.LoadProjectRef` validation message) but, unlike Go, does not then use - that ref for the remote lookup. Go's `cmd/services.go` (deleted in CLI-1970; - last present at commit 7b469f5b3) treats the validation failure as - non-fatal and still calls `listRemoteImages` with the malformed - value; TS skips the remote lookup instead, since the ref is embedded - unescaped into the tenant gateway hostname and a malformed value could - redirect the service-role key to an attacker-controlled host. Intentional - TS-only hardening, not a parity bug — see - [`services/SIDE_EFFECTS.md`](../src/commands/services/SIDE_EFFECTS.md). -- `db pull` in-sync (`"No schema changes found"`) keeps Go's message and its non-zero - exit code, but replaces the generic "Try rerunning the command with --debug to - troubleshoot the error." stderr footer with an explanatory suggestion line - ("The remote database is already in sync with your local migrations — nothing to - pull."). An in-sync database is a finding, not a failure to troubleshoot, so the - debug hint sent users chasing a non-existent bug. Message text and exit code — the - parts scripts depend on — are unchanged. -- Edge Runtime's Docker container `--ulimit nofile` value (`functions serve` and `start`): Go - hardcodes `nofile=65536:65536`, raised from the daemon default to accommodate FD usage from - many concurrent Deno isolates (supabase/cli#5151). TS clamps that value to the host's own hard - nofile limit on Linux (`@supabase/stack`'s `edgeRuntimeNofileUlimit`, via - `process.report`'s `userLimits`), so a constrained sandbox (hard cap below 65536) can still start the - container instead of failing outright (CLI-2220). The CLI process's own limit is used as a - proxy for the daemon's — exact in the sandboxes this targets, where both share the cap; a - Linux client more constrained than its daemon (remote `DOCKER_HOST`, mounted socket) just - gets a smaller fd budget, never a failed start. When the clamp lowers the request, the CLI's - `functions serve`/`start` bring-up warns with the reduced limit. The `@supabase/stack` service - builder (`stack start`) applies the same clamp silently: its defs are built without - an output channel, and in managed mode inside the daemon process, so a user-visible warning - there needs a diagnostics channel on `BuildResult` first; the applied value stays visible via - `docker inspect`. diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md deleted file mode 100644 index 5e37434a43..0000000000 --- a/apps/cli/docs/go-cli-porting-status.md +++ /dev/null @@ -1,66 +0,0 @@ -# Go CLI Delegation - -> Still named `go-cli-porting-status.md` for historical reasons — the per-command porting tracker -> this file once held is gone now that the port is done; it documents only the residual Go -> delegation surface below. - -The Go→TypeScript port is complete (CLI-1970). The bundled `supabase-go` binary and the -`apps/cli-go/` tree contain **only** the delegation surface in the table below — Go source for -every other command was deleted outright once nothing in the TypeScript CLI could reach it, -directly or indirectly. For any other command's former Go source, the reference is the last commit -with it intact: `7b469f5b3` (CLI-1966's `internal/start` pin remains its own, separate commit, -`a253ccba2`). - -See [`binary-distribution.md`](./binary-distribution.md) for how these two binaries are packaged, -resolved at runtime, and sized. The TypeScript CLI is the source of truth for all CLI behavior; -`apps/cli-go/` is authoritative only for the proxied commands below, and the whole surface is -slated for cleanup and removal ([ADR 0016](../../../docs/adr/0016-port-completion-and-go-cli-authority-scope.md) -records the earlier transition policy). - -## The delegation surface - -| Command/path | TS proxy site | Go implementation (in-tree) | Why it stays | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `db diff --use-pg-schema` | [`src/commands/db/diff/diff.handler.ts`](../src/commands/db/diff/diff.handler.ts) (delegate path) | `internal/db/diff/pgschema.go` | Wraps the Go-only `stripe/pg-schema-diff` library (CLI-1960); deprecated but sanctioned. | -| `db branch create\|delete\|list\|switch` | `src/commands/db/branch/*/` | `legacy/branch/*` | Go-deprecated wrapped commands kept indefinitely (CLI-1964 cancelled: dropping them was ruled a breaking change not worth shipping). | -| `db remote changes` | `src/commands/db/remote/changes/` | inline in `cmd/db.go` over `internal/db/diff` | Same CLI-1964 ruling. `db remote commit` is native `db pull`. | -| `gen keys` | `src/commands/gen/keys/` | `legacy/keys` | Same ruling; requires `--experimental`. | -| `functions download --legacy-bundle` (hidden flag) | `src/shared/functions/download.ts` `makeGoProxyLegacyBundleArgs` | `internal/functions/download` | Legacy Deno bundle extraction (CLI-1963). | - -## Mechanics - -All delegation goes through the shared `GoProxy` service -([`src/command-internal/go-proxy.service.ts`](../src/command-internal/go-proxy.service.ts), -[`go-proxy.layer.ts`](../src/command-internal/go-proxy.layer.ts)): - -- **Binary resolution order**: `SUPABASE_GO_BINARY` env var → binary co-located with the compiled - shim → the platform's `@supabase/cli-` npm package. There is deliberately **no** - `PATH` fallback (CLI-1488: the shim itself is what's on `PATH`, so falling back would re-invoke - it and fork-bomb) — resolution failure is a hard error with install guidance. PATH-installed - setups work via the co-location step: `supabase-go` sits next to the `supabase` shim. -- **Global-flag forwarding**: `cli/root.ts` translates the CLI's global flags - (`--output`, `--profile`, `--debug`, `--workdir`, `--experimental`, `--network-id`, `--yes`, - `--dns-resolver`, `--create-ticket`, `--agent`) into Go-style argv ahead of every proxied - invocation. -- **Child telemetry suppression**: a proxy handler that itself owns the parent - `cli_command_executed` event (wrapped in TS command instrumentation) suppresses the child's own - telemetry; a bare pass-through proxy leaves it enabled so the Go child stays the sole emitter. -- **Stack-mode state**: when the project keeps its state outside `supabase/.temp`, `GoProxy` sets - `SUPABASE_INTERNAL_STACK_STATE=1` so the child skips `supabase/.temp`, and passes the linked - project from `supabase/.link.json` as the JSON of `linked-project.json` in - `SUPABASE_INTERNAL_LINKED_PROJECT` so the child keeps its telemetry groups. A command that - passes `--project-ref` also passes the resolved target's `pooler_url` in - `SUPABASE_INTERNAL_POOLER_URL` when that ref is the linked one; the child reads it in place of - `supabase/.temp/pooler-url` for its IPv4 pooler fallback. - -The spawn surface is guarded by -[`apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts`](../../cli-e2e/src/tests/go-binary-surface.e2e.test.ts) — -trimming the binary past this surface fails CI. - -The former pg-delta `__catalog`/`db start` seam (spawned directly by `db schema declarative -generate|sync`, outside `GoProxy`) was ported native in CLI-1970 and no longer exists. - -## See also - -TS-only flags and deliberate behavioral divergences from the old Go CLI live in -[`go-cli-divergences.md`](./go-cli-divergences.md). diff --git a/apps/cli/docs/go-cli-reference.md b/apps/cli/docs/go-cli-reference.md deleted file mode 100644 index e557eefadf..0000000000 --- a/apps/cli/docs/go-cli-reference.md +++ /dev/null @@ -1,2128 +0,0 @@ -# Old Go CLI Reference - -> Complete help output for the old Go-based `supabase` CLI. -> Use this document as the raw parity reference for the residual Go delegation surface in [`go-cli-porting-status.md`](./go-cli-porting-status.md) and the TS divergences tracked in [`go-cli-divergences.md`](./go-cli-divergences.md). - -## Global Flags - -These flags are available on all commands: - -``` -Flags: - --create-ticket create a support ticket for any CLI error - --debug output debug logs to stderr - --dns-resolver [ native | https ] lookup domain names using the specified resolver (default native) - --experimental enable experimental features - -h, --help help for supabase - --network-id string use the specified docker network instead of a generated one - -o, --output [ env | pretty | json | toml | yaml ] output format of status variables (default pretty) - --profile string use a specific profile for connecting to Supabase API (default "supabase") - --workdir string path to a Supabase project directory - --yes answer yes to all prompts -``` - -## Table of Contents - -- [Quick Start](#quick-start) - - [bootstrap](#bootstrap) -- [Local Development](#local-development) - - [init](#init) - - [link](#link) - - [unlink](#unlink) - - [login](#login) - - [logout](#logout) - - [start](#start) - - [stop](#stop) - - [status](#status) - - [services](#services) - - [db](#db) - - [gen](#gen) - - [inspect](#inspect) - - [migration](#migration) - - [seed](#seed) - - [test](#test) -- [Management APIs](#management-apis) - - [backups](#backups) - - [branches](#branches) - - [config](#config) - - [domains](#domains) - - [encryption](#encryption) - - [functions](#functions) - - [network-bans](#network-bans) - - [network-restrictions](#network-restrictions) - - [orgs](#orgs) - - [postgres-config](#postgres-config) - - [projects](#projects) - - [secrets](#secrets) - - [snippets](#snippets) - - [ssl-enforcement](#ssl-enforcement) - - [sso](#sso) - - [storage](#storage) - - [vanity-subdomains](#vanity-subdomains) -- [Additional Commands](#additional-commands) - - [completion](#completion) - - [help](#help-1) - ---- - -## Quick Start - -### bootstrap - -``` -Bootstrap a Supabase project from a starter template - -Usage: - supabase bootstrap [template] [flags] - -Flags: - -h, --help help for bootstrap - -p, --password string Password to your remote Postgres database. -``` - ---- - -## Local Development - -### init - -``` -Initialize a local project - -Usage: - supabase init [flags] - -Flags: - --force Overwrite existing supabase/config.toml. - -h, --help help for init - -i, --interactive Enables interactive mode to configure IDE settings. - --use-orioledb Use OrioleDB storage engine for Postgres. -``` - -### link - -``` -Link to a Supabase project - -Usage: - supabase link [flags] - -Flags: - -h, --help help for link - -p, --password string Password to your remote Postgres database. - --project-ref string Project ref of the Supabase project. - --skip-pooler Use direct connection instead of pooler. -``` - -### unlink - -``` -Unlink a Supabase project - -Usage: - supabase unlink [flags] - -Flags: - -h, --help help for unlink -``` - -### login - -``` -Authenticate using an access token - -Usage: - supabase login [flags] - -Flags: - -h, --help help for login - --name string Name that will be used to store token in your settings (default "built-in token name generator") - --no-browser Do not open browser automatically - --token string Use provided token instead of automatic login flow -``` - -### logout - -``` -Log out and delete access tokens locally - -Usage: - supabase logout [flags] - -Flags: - -h, --help help for logout -``` - -### start - -``` -Start containers for Supabase local development - -Usage: - supabase start [flags] - -Flags: - -x, --exclude strings Names of containers to not start. [gotrue,realtime,storage-api,imgproxy,kong,mailpit,postgrest,postgres-meta,studio,edge-runtime,logflare,vector,supavisor] - -h, --help help for start - --ignore-health-check Ignore unhealthy services and exit 0 - --sandbox Run in sandbox mode using native binaries (experimental) -``` - -### stop - -``` -Stop all local Supabase containers - -Usage: - supabase stop [flags] - -Flags: - --all Stop all local Supabase instances from all projects across the machine. - -h, --help help for stop - --no-backup Deletes all data volumes after stopping. - --project-id string Local project ID to stop. -``` - -### status - -``` -Show status of local Supabase containers - -Usage: - supabase status [flags] - -Examples: - supabase status -o env --override-name api.url=NEXT_PUBLIC_SUPABASE_URL - supabase status -o json - -Flags: - -h, --help help for status - --override-name strings Override specific variable names. -``` - -### services - -``` -Show versions of all Supabase services - -Usage: - supabase services [flags] - -Flags: - -h, --help help for services -``` - -### db - -``` -Manage Postgres databases - -Usage: - supabase db [command] - -Available Commands: - diff Diffs the local database for schema changes - dump Dumps data or schemas from the remote database - lint Checks local database for typing error - pull Pull schema from the remote database - push Push new migrations to the remote database - reset Resets the local database to current migrations - start Starts local Postgres database - -Flags: - -h, --help help for db -``` - -#### db diff - -``` -Diffs the local database for schema changes - -Usage: - supabase db diff [flags] - -Flags: - --db-url string Diffs against the database specified by the connection string (must be percent-encoded). - -f, --file string Saves schema diff to a new migration file. - -h, --help help for diff - --linked Diffs local migration files against the linked project. - --local Diffs local migration files against the local database. (default true) - -s, --schema strings Comma separated list of schema to include. - --use-migra Use migra to generate schema diff. (default true) - --use-pg-delta Use pg-delta to generate schema diff. - --use-pg-schema Use pg-schema-diff to generate schema diff. - --use-pgadmin Use pgAdmin to generate schema diff. -``` - -#### db dump - -``` -Dumps data or schemas from the remote database - -Usage: - supabase db dump [flags] - -Flags: - --data-only Dumps only data records. - --db-url string Dumps from the database specified by the connection string (must be percent-encoded). - --dry-run Prints the pg_dump script that would be executed. - -x, --exclude strings List of schema.tables to exclude from data-only dump. - -f, --file string File path to save the dumped contents. - -h, --help help for dump - --keep-comments Keeps commented lines from pg_dump output. - --linked Dumps from the linked project. (default true) - --local Dumps from the local database. - -p, --password string Password to your remote Postgres database. - --role-only Dumps only cluster roles. - -s, --schema strings Comma separated list of schema to include. - --use-copy Use copy statements in place of inserts. -``` - -#### db lint - -``` -Checks local database for typing error - -Usage: - supabase db lint [flags] - -Flags: - --db-url string Lints the database specified by the connection string (must be percent-encoded). - --fail-on [ none | warning | error ] Error level to exit with non-zero status. (default none) - -h, --help help for lint - --level [ warning | error ] Error level to emit. (default warning) - --linked Lints the linked project for schema errors. - --local Lints the local database for schema errors. (default true) - -s, --schema strings Comma separated list of schema to include. -``` - -#### db pull - -``` -Pull schema from the remote database - -Usage: - supabase db pull [migration name] [flags] - -Flags: - --db-url string Pulls from the database specified by the connection string (must be percent-encoded). - -h, --help help for pull - --linked Pulls from the linked project. (default true) - --local Pulls from the local database. - -p, --password string Password to your remote Postgres database. - -s, --schema strings Comma separated list of schema to include. -``` - -#### db push - -``` -Push new migrations to the remote database - -Usage: - supabase db push [flags] - -Flags: - --db-url string Pushes to the database specified by the connection string (must be percent-encoded). - --dry-run Print the migrations that would be applied, but don't actually apply them. - -h, --help help for push - --include-all Include all migrations not found on remote history table. - --include-roles Include custom roles from supabase/roles.sql. - --include-seed Include seed data from your config. - --linked Pushes to the linked project. (default true) - --local Pushes to the local database. - -p, --password string Password to your remote Postgres database. -``` - -#### db reset - -``` -Resets the local database to current migrations - -Usage: - supabase db reset [flags] - -Flags: - --db-url string Resets the database specified by the connection string (must be percent-encoded). - -h, --help help for reset - --last uint Reset up to the last n migration versions. - --linked Resets the linked project with local migrations. - --local Resets the local database with local migrations. (default true) - --no-seed Skip running the seed script after reset. - --version string Reset up to the specified version. -``` - -#### db start - -``` -Starts local Postgres database - -Usage: - supabase db start [flags] - -Flags: - --from-backup string Path to a logical backup file. - -h, --help help for start -``` - -### gen - -``` -Run code generation tools - -Usage: - supabase gen [command] - -Available Commands: - bearer-jwt Generate a Bearer Auth JWT for accessing Data API - signing-key Generate a JWT signing key - types Generate types from Postgres schema - -Flags: - -h, --help help for gen -``` - -#### gen bearer-jwt - -``` -Generate a Bearer Auth JWT for accessing Data API - -Usage: - supabase gen bearer-jwt [flags] - -Flags: - --exp time Expiry timestamp for this token. - -h, --help help for bearer-jwt - --payload string Custom claims in JSON format. (default "{}") - --role string Postgres role to use. - --sub string User ID to impersonate. (default "anonymous") - --valid-for duration Validity duration for this token. (default 30m0s) -``` - -#### gen signing-key - -``` -Securely generate a private JWT signing key for use in the CLI or to import in the dashboard. - -Supported algorithms: - ES256 - ECDSA with P-256 curve and SHA-256 (recommended) - RS256 - RSA with SHA-256 - -Usage: - supabase gen signing-key [flags] - -Flags: - --algorithm [ RS256 | ES256 ] Algorithm for signing key generation. (default ES256) - --append Append new key to existing keys file instead of overwriting. - -h, --help help for signing-key -``` - -#### gen types - -``` -Generate types from Postgres schema - -Usage: - supabase gen types [flags] - -Examples: - supabase gen types --local - supabase gen types --linked --lang=go - supabase gen types --project-id abc-def-123 --schema public --schema private - supabase gen types --db-url 'postgresql://...' --schema public --schema auth - -Flags: - --db-url string Generate types from a database url. - -h, --help help for types - --lang [ typescript | go | swift | python ] Output language of the generated types. (default typescript) - --linked Generate types from the linked project. - --local Generate types from the local dev database. - --postgrest-v9-compat Generate types compatible with PostgREST v9 and below. - --project-id string Generate types from a project ID. - --query-timeout duration Maximum timeout allowed for the database query. (default 15s) - -s, --schema strings Comma separated list of schema to include. - --swift-access-control [ internal | public ] Access control for Swift generated types. (default internal) -``` - -### inspect - -``` -Tools to inspect your Supabase project - -Usage: - supabase inspect [command] - -Available Commands: - db Tools to inspect your Supabase database - report Generate a CSV output for all inspect commands - -Flags: - --db-url string Inspect the database specified by the connection string (must be percent-encoded). - -h, --help help for inspect - --linked Inspect the linked project. (default true) - --local Inspect the local database. -``` - -#### inspect db - -``` -Tools to inspect your Supabase database - -Usage: - supabase inspect db [command] - -Available Commands: - bloat Estimates space allocated to a relation that is full of dead tuples - blocking Show queries that are holding locks and the queries that are waiting for them to be released - calls Show queries from pg_stat_statements ordered by total times called - db-stats Show stats such as cache hit rates, total sizes, and WAL size - index-stats Show combined index size, usage percent, scan counts, and unused status - locks Show queries which have taken out an exclusive lock on a relation - long-running-queries Show currently running queries running for longer than 5 minutes - outliers Show queries from pg_stat_statements ordered by total execution time - replication-slots Show information about replication slots on the database - role-stats Show information about roles on the database - table-stats Show combined table size, index size, and estimated row count - traffic-profile Show read/write activity ratio for tables based on block I/O operations - vacuum-stats Show statistics related to vacuum operations per table - -Flags: - -h, --help help for db -``` - -##### inspect db bloat - -``` -Estimates space allocated to a relation that is full of dead tuples - -Usage: - supabase inspect db bloat [flags] - -Flags: - -h, --help help for bloat -``` - -##### inspect db blocking - -``` -Show queries that are holding locks and the queries that are waiting for them to be released - -Usage: - supabase inspect db blocking [flags] - -Flags: - -h, --help help for blocking -``` - -##### inspect db calls - -``` -Show queries from pg_stat_statements ordered by total times called - -Usage: - supabase inspect db calls [flags] - -Flags: - -h, --help help for calls -``` - -##### inspect db db-stats - -``` -Show stats such as cache hit rates, total sizes, and WAL size - -Usage: - supabase inspect db db-stats [flags] - -Flags: - -h, --help help for db-stats -``` - -##### inspect db index-stats - -``` -Show combined index size, usage percent, scan counts, and unused status - -Usage: - supabase inspect db index-stats [flags] - -Flags: - -h, --help help for index-stats -``` - -##### inspect db locks - -``` -Show queries which have taken out an exclusive lock on a relation - -Usage: - supabase inspect db locks [flags] - -Flags: - -h, --help help for locks -``` - -##### inspect db long-running-queries - -``` -Show currently running queries running for longer than 5 minutes - -Usage: - supabase inspect db long-running-queries [flags] - -Flags: - -h, --help help for long-running-queries -``` - -##### inspect db outliers - -``` -Show queries from pg_stat_statements ordered by total execution time - -Usage: - supabase inspect db outliers [flags] - -Flags: - -h, --help help for outliers -``` - -##### inspect db replication-slots - -``` -Show information about replication slots on the database - -Usage: - supabase inspect db replication-slots [flags] - -Flags: - -h, --help help for replication-slots -``` - -##### inspect db role-stats - -``` -Show information about roles on the database - -Usage: - supabase inspect db role-stats [flags] - -Flags: - -h, --help help for role-stats -``` - -##### inspect db table-stats - -``` -Show combined table size, index size, and estimated row count - -Usage: - supabase inspect db table-stats [flags] - -Flags: - -h, --help help for table-stats -``` - -##### inspect db traffic-profile - -``` -Show read/write activity ratio for tables based on block I/O operations - -Usage: - supabase inspect db traffic-profile [flags] - -Flags: - -h, --help help for traffic-profile -``` - -##### inspect db vacuum-stats - -``` -Show statistics related to vacuum operations per table - -Usage: - supabase inspect db vacuum-stats [flags] - -Flags: - -h, --help help for vacuum-stats -``` - -#### inspect report - -``` -Generate a CSV output for all inspect commands - -Usage: - supabase inspect report [flags] - -Flags: - -h, --help help for report - --output-dir string Path to save CSV files in (default ".") -``` - -### migration - -``` -Manage database migration scripts - -Usage: - supabase migration [command] - -Aliases: - migration, migrations - -Available Commands: - down Resets applied migrations up to the last n versions - fetch Fetch migration files from history table - list List local and remote migrations - new Create an empty migration script - repair Repair the migration history table - squash Squash migrations to a single file - up Apply pending migrations to local database - -Flags: - -h, --help help for migration -``` - -#### migration down - -``` -Resets applied migrations up to the last n versions - -Usage: - supabase migration down [flags] - -Flags: - --db-url string Resets applied migrations on the database specified by the connection string (must be percent-encoded). - -h, --help help for down - --last uint Reset up to the last n migration versions. (default 1) - --linked Resets applied migrations on the linked project. - --local Resets applied migrations on the local database. (default true) -``` - -#### migration fetch - -``` -Fetch migration files from history table - -Usage: - supabase migration fetch [flags] - -Flags: - --db-url string Fetches migrations from the database specified by the connection string (must be percent-encoded). - -h, --help help for fetch - --linked Fetches migration history from the linked project. (default true) - --local Fetches migration history from the local database. -``` - -#### migration list - -``` -List local and remote migrations - -Usage: - supabase migration list [flags] - -Flags: - --db-url string Lists migrations of the database specified by the connection string (must be percent-encoded). - -h, --help help for list - --linked Lists migrations applied to the linked project. (default true) - --local Lists migrations applied to the local database. - -p, --password string Password to your remote Postgres database. -``` - -#### migration new - -``` -Create an empty migration script - -Usage: - supabase migration new [flags] - -Flags: - -h, --help help for new -``` - -#### migration repair - -``` -Repair the migration history table - -Usage: - supabase migration repair [version] ... [flags] - -Flags: - --db-url string Repairs migrations of the database specified by the connection string (must be percent-encoded). - -h, --help help for repair - --linked Repairs the migration history of the linked project. (default true) - --local Repairs the migration history of the local database. - -p, --password string Password to your remote Postgres database. - --status [ applied | reverted ] Version status to update. -``` - -#### migration squash - -``` -Squash migrations to a single file - -Usage: - supabase migration squash [flags] - -Flags: - --db-url string Squashes migrations of the database specified by the connection string (must be percent-encoded). - -h, --help help for squash - --linked Squashes the migration history of the linked project. - --local Squashes the migration history of the local database. (default true) - -p, --password string Password to your remote Postgres database. - --version string Squash up to the specified version. -``` - -#### migration up - -``` -Apply pending migrations to local database - -Usage: - supabase migration up [flags] - -Flags: - --db-url string Applies migrations to the database specified by the connection string (must be percent-encoded). - -h, --help help for up - --include-all Include all migrations not found on remote history table. - --linked Applies pending migrations to the linked project. - --local Applies pending migrations to the local database. (default true) -``` - -### seed - -``` -Seed a Supabase project from supabase/config.toml - -Usage: - supabase seed [command] - -Available Commands: - buckets Seed buckets declared in [storage.buckets] - -Flags: - -h, --help help for seed - --linked Seeds the linked project. - --local Seeds the local database. (default true) -``` - -#### seed buckets - -``` -Seed buckets declared in [storage.buckets] - -Usage: - supabase seed buckets [flags] - -Flags: - -h, --help help for buckets -``` - -### test - -``` -Run tests on local Supabase containers - -Usage: - supabase test [command] - -Available Commands: - db Tests local database with pgTAP - new Create a new test file - -Flags: - -h, --help help for test -``` - -#### test db - -``` -Tests local database with pgTAP - -Usage: - supabase test db [path] ... [flags] - -Flags: - --db-url string Tests the database specified by the connection string (must be percent-encoded). - -h, --help help for db - --linked Runs pgTAP tests on the linked project. - --local Runs pgTAP tests on the local database. (default true) -``` - -#### test new - -``` -Create a new test file - -Usage: - supabase test new [flags] - -Flags: - -h, --help help for new - -t, --template [ pgtap ] Template framework to generate. (default pgtap) -``` - ---- - -## Management APIs - -### backups - -``` -Manage Supabase physical backups - -Usage: - supabase backups [command] - -Available Commands: - list Lists available physical backups - restore Restore to a specific timestamp using PITR - -Flags: - -h, --help help for backups - --project-ref string Project ref of the Supabase project. -``` - -#### backups list - -``` -Lists available physical backups - -Usage: - supabase backups list [flags] - -Flags: - -h, --help help for list -``` - -#### backups restore - -``` -Restore to a specific timestamp using PITR - -Usage: - supabase backups restore [flags] - -Flags: - -h, --help help for restore - -t, --timestamp int The recovery time target in seconds since epoch. -``` - -### branches - -``` -Manage Supabase preview branches - -Usage: - supabase branches [command] - -Available Commands: - create Create a preview branch - delete Delete a preview branch - get Retrieve details of a preview branch - list List all preview branches - pause Pause a preview branch - unpause Unpause a preview branch - update Update a preview branch - -Flags: - -h, --help help for branches - --project-ref string Project ref of the Supabase project. -``` - -#### branches create - -``` -Create a preview branch for the linked project. - -Usage: - supabase branches create [name] [flags] - -Flags: - -h, --help help for create - --notify-url string URL to notify when branch is active healthy. - --persistent Whether to create a persistent branch. - --region string Select a region to deploy the branch database. - --size string Select a desired instance size for the branch database. - --with-data Whether to clone production data to the branch database. -``` - -#### branches delete - -``` -Delete a preview branch by its name or ID. - -Usage: - supabase branches delete [name] [flags] - -Flags: - -h, --help help for delete -``` - -#### branches get - -``` -Retrieve details of the specified preview branch. - -Usage: - supabase branches get [name] [flags] - -Flags: - -h, --help help for get -``` - -#### branches list - -``` -List all preview branches of the linked project. - -Usage: - supabase branches list [flags] - -Flags: - -h, --help help for list -``` - -#### branches pause - -``` -Pause a preview branch - -Usage: - supabase branches pause [name] [flags] - -Flags: - -h, --help help for pause -``` - -#### branches unpause - -``` -Unpause a preview branch - -Usage: - supabase branches unpause [name] [flags] - -Flags: - -h, --help help for unpause -``` - -#### branches update - -``` -Update a preview branch by its name or ID. - -Usage: - supabase branches update [name] [flags] - -Flags: - --git-branch string Change the associated git branch. - -h, --help help for update - --name string Rename the preview branch. - --notify-url string URL to notify when branch is active healthy. - --persistent Switch between ephemeral and persistent branch. - --status string Override the current branch status. -``` - -### config - -``` -Manage Supabase project configurations - -Usage: - supabase config [command] - -Available Commands: - push Pushes local config.toml to the linked project - -Flags: - -h, --help help for config - --project-ref string Project ref of the Supabase project. -``` - -#### config push - -``` -Pushes local config.toml to the linked project - -Usage: - supabase config push [flags] - -Flags: - -h, --help help for push -``` - -### domains - -``` -Manage custom domain names for Supabase projects. - -Use of custom domains and vanity subdomains is mutually exclusive. - -Usage: - supabase domains [command] - -Available Commands: - activate Activate the custom hostname for a project - create Create a custom hostname - delete Deletes the custom hostname config for your project - get Get the current custom hostname config - reverify Re-verify the custom hostname config for your project - -Flags: - -h, --help help for domains - --include-raw-output Include raw output (useful for debugging). - --project-ref string Project ref of the Supabase project. -``` - -#### domains activate - -``` -Activates the custom hostname configuration for a project. - -This reconfigures your Supabase project to respond to requests on your custom hostname. -After the custom hostname is activated, your project's auth services will no longer function on the Supabase-provisioned subdomain. - -Usage: - supabase domains activate [flags] - -Flags: - -h, --help help for activate -``` - -#### domains create - -``` -Create a custom hostname for your Supabase project. - -Expects your custom hostname to have a CNAME record to your Supabase project's subdomain. - -Usage: - supabase domains create [flags] - -Flags: - --custom-hostname string The custom hostname to use for your Supabase project. - -h, --help help for create -``` - -#### domains delete - -``` -Deletes the custom hostname config for your project - -Usage: - supabase domains delete [flags] - -Flags: - -h, --help help for delete -``` - -#### domains get - -``` -Retrieve the custom hostname config for your project, as stored in the Supabase platform. - -Usage: - supabase domains get [flags] - -Flags: - -h, --help help for get -``` - -#### domains reverify - -``` -Re-verify the custom hostname config for your project - -Usage: - supabase domains reverify [flags] - -Flags: - -h, --help help for reverify -``` - -### encryption - -``` -Manage encryption keys of Supabase projects - -Usage: - supabase encryption [command] - -Available Commands: - get-root-key Get the root encryption key of a Supabase project - update-root-key Update root encryption key of a Supabase project - -Flags: - -h, --help help for encryption - --project-ref string Project ref of the Supabase project. -``` - -#### encryption get-root-key - -``` -Get the root encryption key of a Supabase project - -Usage: - supabase encryption get-root-key [flags] - -Flags: - -h, --help help for get-root-key -``` - -#### encryption update-root-key - -``` -Update root encryption key of a Supabase project - -Usage: - supabase encryption update-root-key [flags] - -Flags: - -h, --help help for update-root-key -``` - -### functions - -``` -Manage Supabase Edge functions - -Usage: - supabase functions [command] - -Available Commands: - delete Delete a Function from Supabase - deploy Deploy a Function to Supabase - download Download a Function from Supabase - list List all Functions in Supabase - new Create a new Function locally - serve Serve all Functions locally - -Flags: - -h, --help help for functions -``` - -#### functions delete - -``` -Delete a Function from the linked Supabase project. This does NOT remove the Function locally. - -Usage: - supabase functions delete [flags] - -Flags: - -h, --help help for delete - --project-ref string Project ref of the Supabase project. -``` - -#### functions deploy - -``` -Deploy a Function to the linked Supabase project. - -Usage: - supabase functions deploy [Function name] [flags] - -Flags: - -h, --help help for deploy - --import-map string Path to import map file. - -j, --jobs uint Maximum number of parallel jobs. (default 1) - --no-verify-jwt Disable JWT verification for the Function. - --project-ref string Project ref of the Supabase project. - --prune Delete Functions that exist in Supabase project but not locally. - --use-api Bundle functions server-side without using Docker. -``` - -#### functions download - -``` -Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions. - -Usage: - supabase functions download [Function name] [flags] - -Flags: - -h, --help help for download - --project-ref string Project ref of the Supabase project. - --use-api Unbundle functions server-side without using Docker. -``` - -#### functions list - -``` -List all Functions in the linked Supabase project. - -Usage: - supabase functions list [flags] - -Flags: - -h, --help help for list - --project-ref string Project ref of the Supabase project. -``` - -#### functions new - -``` -Create a new Function locally - -Usage: - supabase functions new [flags] - -Flags: - -h, --help help for new -``` - -#### functions serve - -``` -Serve all Functions locally - -Usage: - supabase functions serve [flags] - -Flags: - --env-file string Path to an env file to be populated to the Function environment. - -h, --help help for serve - --import-map string Path to import map file. - --inspect Alias of --inspect-mode brk. - --inspect-main Allow inspecting the main worker. - --inspect-mode [ run | brk | wait ] Activate inspector capability for debugging. - --no-verify-jwt Disable JWT verification for the Function. -``` - -### network-bans - -``` -Network bans are IPs that get temporarily blocked if their traffic pattern looks abusive (e.g. multiple failed auth attempts). - -The subcommands help you view the current bans, and unblock IPs if desired. - -Usage: - supabase network-bans [command] - -Available Commands: - get Get the current network bans - remove Remove a network ban - -Flags: - -h, --help help for network-bans - --project-ref string Project ref of the Supabase project. -``` - -#### network-bans get - -``` -Get the current network bans - -Usage: - supabase network-bans get [flags] - -Flags: - -h, --help help for get -``` - -#### network-bans remove - -``` -Remove a network ban - -Usage: - supabase network-bans remove [flags] - -Flags: - --db-unban-ip strings IP to allow DB connections from. - -h, --help help for remove -``` - -### network-restrictions - -``` -Manage network restrictions - -Usage: - supabase network-restrictions [command] - -Available Commands: - get Get the current network restrictions - update Update network restrictions - -Flags: - -h, --help help for network-restrictions - --project-ref string Project ref of the Supabase project. -``` - -#### network-restrictions get - -``` -Get the current network restrictions - -Usage: - supabase network-restrictions get [flags] - -Flags: - -h, --help help for get -``` - -#### network-restrictions update - -``` -Update network restrictions - -Usage: - supabase network-restrictions update [flags] - -Flags: - --append Append to existing restrictions instead of replacing them. - --bypass-cidr-checks Bypass some of the CIDR validation checks. - --db-allow-cidr strings CIDR to allow DB connections from. - -h, --help help for update -``` - -### orgs - -``` -Manage Supabase organizations - -Usage: - supabase orgs [command] - -Available Commands: - create Create an organization - list List all organizations - -Flags: - -h, --help help for orgs -``` - -#### orgs create - -``` -Create an organization for the logged-in user. - -Usage: - supabase orgs create [flags] - -Flags: - -h, --help help for create -``` - -#### orgs list - -``` -List all organizations the logged-in user belongs. - -Usage: - supabase orgs list [flags] - -Flags: - -h, --help help for list -``` - -### postgres-config - -``` -Manage Postgres database config - -Usage: - supabase postgres-config [command] - -Available Commands: - delete Delete specific Postgres database config overrides - get Get the current Postgres database config overrides - update Update Postgres database config - -Flags: - -h, --help help for postgres-config - --project-ref string Project ref of the Supabase project. -``` - -#### postgres-config delete - -``` -Delete specific config overrides, reverting them to their default values. - -Usage: - supabase postgres-config delete [flags] - -Flags: - --config strings Config keys to delete (comma-separated) - -h, --help help for delete - --no-restart Do not restart the database after deleting config. -``` - -#### postgres-config get - -``` -Get the current Postgres database config overrides - -Usage: - supabase postgres-config get [flags] - -Flags: - -h, --help help for get -``` - -#### postgres-config update - -``` -Overriding the default Postgres config could result in unstable database behavior. -Custom configuration also overrides the optimizations generated based on the compute add-ons in use. - -Usage: - supabase postgres-config update [flags] - -Flags: - --config strings Config overrides specified as a 'key=value' pair - -h, --help help for update - --no-restart Do not restart the database after updating config. - --replace-existing-overrides If true, replaces all existing overrides with the ones provided. If false (default), merges existing overrides with the ones provided. -``` - -### projects - -``` -Manage Supabase projects - -Usage: - supabase projects [command] - -Available Commands: - api-keys List all API keys for a Supabase project - create Create a project on Supabase - delete Delete a Supabase project - list List all Supabase projects - -Flags: - -h, --help help for projects -``` - -#### projects api-keys - -``` -List all API keys for a Supabase project - -Usage: - supabase projects api-keys [flags] - -Flags: - -h, --help help for api-keys - --project-ref string Project ref of the Supabase project. -``` - -#### projects create - -``` -Create a project on Supabase - -Usage: - supabase projects create [project name] [flags] - -Examples: -supabase projects create my-project --org-id cool-green-pqdr0qc --db-password ******** --region us-east-1 - -Flags: - --db-password string Database password of the project. - -h, --help help for create - --org-id string Organization ID to create the project in. - --region string Select a region close to you for the best performance. - --size string Select a desired instance size for your project. -``` - -#### projects delete - -``` -Delete a Supabase project - -Usage: - supabase projects delete [ref] [flags] - -Flags: - -h, --help help for delete -``` - -#### projects list - -``` -List all Supabase projects the logged-in user can access. - -Usage: - supabase projects list [flags] - -Flags: - -h, --help help for list -``` - -### secrets - -``` -Manage Supabase secrets - -Usage: - supabase secrets [command] - -Available Commands: - list List all secrets on Supabase - set Set a secret(s) on Supabase - unset Unset a secret(s) on Supabase - -Flags: - -h, --help help for secrets - --project-ref string Project ref of the Supabase project. -``` - -#### secrets list - -``` -List all secrets in the linked project. - -Usage: - supabase secrets list [flags] - -Flags: - -h, --help help for list -``` - -#### secrets set - -``` -Set a secret(s) to the linked Supabase project. - -Usage: - supabase secrets set ... [flags] - -Flags: - --env-file string Read secrets from a .env file. - -h, --help help for set -``` - -#### secrets unset - -``` -Unset a secret(s) from the linked Supabase project. - -Usage: - supabase secrets unset [NAME] ... [flags] - -Flags: - -h, --help help for unset -``` - -### snippets - -``` -Manage Supabase SQL snippets - -Usage: - supabase snippets [command] - -Available Commands: - download Download contents of a SQL snippet - list List all SQL snippets - -Flags: - -h, --help help for snippets - --project-ref string Project ref of the Supabase project. -``` - -#### snippets download - -``` -Download contents of the specified SQL snippet. - -Usage: - supabase snippets download [flags] - -Flags: - -h, --help help for download -``` - -#### snippets list - -``` -List all SQL snippets of the linked project. - -Usage: - supabase snippets list [flags] - -Flags: - -h, --help help for list -``` - -### ssl-enforcement - -``` -Manage SSL enforcement configuration - -Usage: - supabase ssl-enforcement [command] - -Available Commands: - get Get the current SSL enforcement configuration - update Update SSL enforcement configuration - -Flags: - -h, --help help for ssl-enforcement - --project-ref string Project ref of the Supabase project. -``` - -#### ssl-enforcement get - -``` -Get the current SSL enforcement configuration - -Usage: - supabase ssl-enforcement get [flags] - -Flags: - -h, --help help for get -``` - -#### ssl-enforcement update - -``` -Update SSL enforcement configuration - -Usage: - supabase ssl-enforcement update [flags] - -Flags: - --disable-db-ssl-enforcement Whether the DB should disable SSL enforcement for all external connections. - --enable-db-ssl-enforcement Whether the DB should enable SSL enforcement for all external connections. - -h, --help help for update -``` - -### sso - -``` -Manage Single Sign-On (SSO) authentication for projects - -Usage: - supabase sso [command] - -Available Commands: - add Add a new SSO identity provider - info Returns the SAML SSO settings required for the identity provider - list List all SSO identity providers for a project - remove Remove an existing SSO identity provider - show Show information about an SSO identity provider - update Update information about an SSO identity provider - -Flags: - -h, --help help for sso - --project-ref string Project ref of the Supabase project. -``` - -#### sso add - -``` -Add and configure a new connection to a SSO identity provider to your Supabase project. - -Usage: - supabase sso add [flags] - -Examples: - supabase sso add --type saml --project-ref mwjylndxudmiehsxhmmz --metadata-url 'https://...' --domains example.com - -Flags: - --attribute-mapping-file string File containing a JSON mapping between SAML attributes to custom JWT claims. - --domains strings Comma separated list of email domains to associate with the added identity provider. - -h, --help help for add - --metadata-file string File containing a SAML 2.0 Metadata XML document describing the identity provider. - --metadata-url string URL pointing to a SAML 2.0 Metadata XML document describing the identity provider. - --name-id-format string URI reference representing the classification of string-based identifier information. - --skip-url-validation Whether local validation of the SAML 2.0 Metadata URL should not be performed. - -t, --type [ saml ] Type of identity provider (according to supported protocol). -``` - -#### sso info - -``` -Returns all of the important SSO information necessary for your project to be registered with a SAML 2.0 compatible identity provider. - -Usage: - supabase sso info [flags] - -Examples: - supabase sso info --project-ref mwjylndxudmiehsxhmmz - -Flags: - -h, --help help for info -``` - -#### sso list - -``` -List all connections to a SSO identity provider to your Supabase project. - -Usage: - supabase sso list [flags] - -Examples: - supabase sso list --project-ref mwjylndxudmiehsxhmmz - -Flags: - -h, --help help for list -``` - -#### sso remove - -``` -Remove a connection to an already added SSO identity provider. Removing the provider will prevent existing users from logging in. Please treat this command with care. - -Usage: - supabase sso remove [flags] - -Examples: - supabase sso remove b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz - -Flags: - -h, --help help for remove -``` - -#### sso show - -``` -Provides the information about an established connection to an identity provider. You can use --metadata to obtain the raw SAML 2.0 Metadata XML document stored in your project's configuration. - -Usage: - supabase sso show [flags] - -Examples: - supabase sso show b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz - -Flags: - -h, --help help for show - --metadata Show SAML 2.0 XML Metadata only -``` - -#### sso update - -``` -Update the configuration settings of a already added SSO identity provider. - -Usage: - supabase sso update [flags] - -Examples: - supabase sso update b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz --add-domains example.com - -Flags: - --add-domains strings Add this comma separated list of email domains to the identity provider. - --attribute-mapping-file string File containing a JSON mapping between SAML attributes to custom JWT claims. - --domains strings Replace domains with this comma separated list of email domains. - -h, --help help for update - --metadata-file string File containing a SAML 2.0 Metadata XML document describing the identity provider. - --metadata-url string URL pointing to a SAML 2.0 Metadata XML document describing the identity provider. - --name-id-format string URI reference representing the classification of string-based identifier information. - --remove-domains strings Remove this comma separated list of email domains from the identity provider. - --skip-url-validation Whether local validation of the SAML 2.0 Metadata URL should not be performed. -``` - -### storage - -``` -Manage Supabase Storage objects - -Usage: - supabase storage [command] - -Available Commands: - cp Copy objects from src to dst path - ls List objects by path prefix - mv Move objects from src to dst path - rm Remove objects by file path - -Flags: - -h, --help help for storage - --linked Connects to Storage API of the linked project. (default true) - --local Connects to Storage API of the local database. -``` - -#### storage cp - -``` -Copy objects from src to dst path - -Usage: - supabase storage cp [flags] - -Examples: -cp readme.md ss:///bucket/readme.md -cp -r docs ss:///bucket/docs -cp -r ss:///bucket/docs . -Flags: - --cache-control string Custom Cache-Control header for HTTP upload. (default "max-age=3600") - --content-type string Custom Content-Type header for HTTP upload. (default "auto-detect") - -h, --help help for cp - -j, --jobs uint Maximum number of parallel jobs. (default 1) - -r, --recursive Recursively copy a directory. -``` - -#### storage ls - -``` -List objects by path prefix - -Usage: - supabase storage ls [path] [flags] - -Examples: -ls ss:///bucket/docs - -Flags: - -h, --help help for ls - -r, --recursive Recursively list a directory. -``` - -#### storage mv - -``` -Move objects from src to dst path - -Usage: - supabase storage mv [flags] - -Examples: -mv -r ss:///bucket/docs ss:///bucket/www/docs - -Flags: - -h, --help help for mv - -r, --recursive Recursively move a directory. -``` - -#### storage rm - -``` -Remove objects by file path - -Usage: - supabase storage rm ... [flags] - -Examples: -rm -r ss:///bucket/docs -rm ss:///bucket/docs/example.md ss:///bucket/readme.md -Flags: - -h, --help help for rm - -r, --recursive Recursively remove a directory. -``` - -### vanity-subdomains - -``` -Manage vanity subdomains for Supabase projects. - -Usage of vanity subdomains and custom domains is mutually exclusive. - -Usage: - supabase vanity-subdomains [command] - -Available Commands: - activate Activate a vanity subdomain - check-availability Checks if a desired subdomain is available for use - delete Deletes a project's vanity subdomain - get Get the current vanity subdomain - -Flags: - -h, --help help for vanity-subdomains - --project-ref string Project ref of the Supabase project. -``` - -#### vanity-subdomains activate - -``` -Activate a vanity subdomain for your Supabase project. - -This reconfigures your Supabase project to respond to requests on your vanity subdomain. -After the vanity subdomain is activated, your project's auth services will no longer function on the {project-ref}.{supabase-domain} hostname. - -Usage: - supabase vanity-subdomains activate [flags] - -Flags: - --desired-subdomain string The desired vanity subdomain to use for your Supabase project. - -h, --help help for activate -``` - -#### vanity-subdomains check-availability - -``` -Checks if a desired subdomain is available for use - -Usage: - supabase vanity-subdomains check-availability [flags] - -Flags: - --desired-subdomain string The desired vanity subdomain to use for your Supabase project. - -h, --help help for check-availability -``` - -#### vanity-subdomains delete - -``` -Deletes the vanity subdomain for a project, and reverts to using the project ref for routing. - -Usage: - supabase vanity-subdomains delete [flags] - -Flags: - -h, --help help for delete -``` - -#### vanity-subdomains get - -``` -Get the current vanity subdomain - -Usage: - supabase vanity-subdomains get [flags] - -Flags: - -h, --help help for get -``` - ---- - -## Additional Commands - -### completion - -``` -Generate the autocompletion script for supabase for the specified shell. -See each sub-command's help for details on how to use the generated script. - -Usage: - supabase completion [command] - -Available Commands: - bash Generate the autocompletion script for bash - fish Generate the autocompletion script for fish - powershell Generate the autocompletion script for powershell - zsh Generate the autocompletion script for zsh - -Flags: - -h, --help help for completion -``` - -#### completion bash - -``` -Generate the autocompletion script for the bash shell. - -This script depends on the 'bash-completion' package. -If it is not installed already, you can install it via your OS's package manager. - -To load completions in your current shell session: - - source <(supabase completion bash) - -To load completions for every new session, execute once: - -#### Linux: - - supabase completion bash > /etc/bash_completion.d/supabase - -#### macOS: - - supabase completion bash > $(brew --prefix)/etc/bash_completion.d/supabase - -You will need to start a new shell for this setup to take effect. - -Usage: - supabase completion bash - -Flags: - -h, --help help for bash - --no-descriptions disable completion descriptions -``` - -#### completion fish - -``` -Generate the autocompletion script for the fish shell. - -To load completions in your current shell session: - - supabase completion fish | source - -To load completions for every new session, execute once: - - supabase completion fish > ~/.config/fish/completions/supabase.fish - -You will need to start a new shell for this setup to take effect. - -Usage: - supabase completion fish [flags] - -Flags: - -h, --help help for fish - --no-descriptions disable completion descriptions -``` - -#### completion powershell - -``` -Generate the autocompletion script for powershell. - -To load completions in your current shell session: - - supabase completion powershell | Out-String | Invoke-Expression - -To load completions for every new session, add the output of the above command -to your powershell profile. - -Usage: - supabase completion powershell [flags] - -Flags: - -h, --help help for powershell - --no-descriptions disable completion descriptions -``` - -#### completion zsh - -``` -Generate the autocompletion script for the zsh shell. - -If shell completion is not already enabled in your environment you will need -to enable it. You can execute the following once: - - echo "autoload -U compinit; compinit" >> ~/.zshrc - -To load completions in your current shell session: - - source <(supabase completion zsh) - -To load completions for every new session, execute once: - -#### Linux: - - supabase completion zsh > "${fpath[1]}/_supabase" - -#### macOS: - - supabase completion zsh > $(brew --prefix)/share/zsh/site-functions/_supabase - -You will need to start a new shell for this setup to take effect. - -Usage: - supabase completion zsh [flags] - -Flags: - -h, --help help for zsh - --no-descriptions disable completion descriptions -``` - -### help - -``` -Help provides help for any command in the application. -Simply type supabase help [path to command] for full details. - -Usage: - supabase help [command] [flags] - -Flags: - -h, --help help for help -``` diff --git a/apps/cli/docs/profile.md b/apps/cli/docs/profile.md index 59949999a6..b5b6065a67 100644 --- a/apps/cli/docs/profile.md +++ b/apps/cli/docs/profile.md @@ -4,8 +4,8 @@ This document captures the intended design for advanced non-default backend rout CLI. The file is intentionally named `profile.md` to preserve continuity with the earlier discussion -and the old Go CLI concept. The design in this document does **not** recommend carrying the old -`profile` abstraction forward. The preferred term in the new CLI is **platform**. +and the previous `profile` concept. The design in this document does **not** recommend carrying the +previous `profile` abstraction forward. The preferred term in the new CLI is **platform**. ## Overview @@ -21,9 +21,9 @@ A much smaller group of users needs to point the CLI somewhere else: The goal is to support those cases cleanly without turning non-default backend routing into a first-class everyday feature. -## Why Not Go-Style Profiles +## Why Not Profiles -The old Go CLI `profile` abstraction became too broad. +The previous `profile` abstraction became too broad. It started as a way to switch API endpoints, but it ended up bundling multiple unrelated concerns: @@ -195,7 +195,7 @@ This design does not propose: - a built-in first-party staging alias in the public UX - customer-specific platform definitions baked into the CLI - plugin or product behavior encoded directly inside platform config -- backward compatibility with Go CLI `profile` names or storage behavior +- backward compatibility with previous `profile` names or storage behavior ## Open Follow-up Work diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 5b949f3ef9..55cc60a409 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -34,7 +34,6 @@ pnpm local-registry Publish the CLI into it from another terminal (current platform only, faster than a cross-platform build): ```sh -# CLI (Bun SFE + Go sidecar — requires Go on PATH and `pnpm repos:install`): pnpm cli-release ``` @@ -44,7 +43,7 @@ Test it: npx --registry http://localhost:4873 supabase@ --version ``` -`[tools/release/local-release.ts](../../../tools/release/local-release.ts)` does the heavy lifting: it builds the platform SFE (+ Go sidecar) and the umbrella `supabase` package, materialises them in a `tmp` dir (so no workspace `package.json` is modified), and publishes both to Verdaccio. The cleanup is automatic even on failure. +`[tools/release/local-release.ts](../../../tools/release/local-release.ts)` does the heavy lifting: it builds the platform SFE and the umbrella `supabase` package, materialises them in a `tmp` dir (so no workspace `package.json` is modified), and publishes both to Verdaccio. The cleanup is automatic even on failure. This is the right ring for: @@ -60,7 +59,7 @@ It is **not** a valid test for Homebrew or Scoop — those paths are covered in This is how you validate the Homebrew formula, Scoop manifest, and GitHub-Release-host resolution on real infrastructure without touching `supabase/`\* repos or risking a clash with an already-installed `supabase` CLI on the reviewer's machine. -Both updater scripts support a `--name ` flag that pushes the formula / manifest under a different name (e.g., `supabase-shim-poc`) — that is, a different filename and Ruby class / scoop manifest. The installed binary is always `supabase` (matching the Go CLI), so PoC reviewers should `brew uninstall supabase` / `scoop uninstall supabase` first if they already have the official CLI installed. +Both updater scripts support a `--name ` flag that pushes the formula / manifest under a different name (e.g., `supabase-shim-poc`) — that is, a different filename and Ruby class / scoop manifest. The installed binary is always `supabase`, so PoC reviewers should `brew uninstall supabase` / `scoop uninstall supabase` first if they already have the official CLI installed. ### One-time setup (per reviewer) @@ -87,7 +86,6 @@ The `--dry-run` flag on both updater scripts produces the `Formula/.rb` an ```sh # Build all eight platform archives + linux packages + checksums.txt. -# Ships the Go sidecar alongside the Bun SFE. bun apps/cli/scripts/build.ts --version 0.0.1 # Render the Homebrew formula against your PoC release host + tap. @@ -176,13 +174,13 @@ Validated on Windows x64 (`v0.0.1`, 2026-04-21): installed with no SmartScreen b ### What to validate -Beyond `--version` and `brew test`, exercise a Phase-0 proxied subcommand that requires the `supabase-go` sidecar: +Beyond `--version` and `brew test`, exercise the actual command tree rather than only a flag resolved at build time: ```sh -supabase completion bash +supabase --help ``` -This must spawn the colocated `supabase-go` and print the generated completion script — not return `NotFound: ChildProcess.spawn (supabase ...)`. (`supabase --version` is served by the Bun wrapper and never touches the sidecar, so it is not a sufficient check on its own.) If it fails, the Homebrew install step is wrong: check that `[apps/cli/scripts/update-homebrew.ts](../scripts/update-homebrew.ts)`'s install-lines block ran `bin.install "supabase-go" if File.exist?("supabase-go")`, and that the built archive actually contains `supabase-go` (it should, for any release build). +This must print the full command tree, not fail or truncate — a corrupted or partial binary can still resolve `--version` (a build-time `--define`) while failing on real command dispatch. ### Local-artifact testing (no GitHub Release upload) @@ -264,7 +262,7 @@ flowchart TD pub --> rel["softprops/action-gh-release (empty draft)
→ upload-release-assets.ts upload (gh release upload per asset)
→ upload-release-assets.ts verify → gh release edit --draft=false
(--latest=false for maintenance)"] rel --> hb["publish-homebrew
App-token-authed clone of homebrew-tap
update-homebrew.ts --name
(beta, stable, maintenance)"] rel --> sc["publish-scoop
App-token-authed clone of scoop-bucket
update-scoop.ts --name
(beta, stable, maintenance)"] - rel --> sucs["setup-cli-smoke
install via supabase/setup-cli
(GitHub Release download)"] + rel --> sucs["setup-cli-smoke
install via supabase/setup-cli
(v1/v2 GitHub Release download, v3 npm)"] hb --> vic["verify-install-channels
real brew/scoop/install-script installs
against the live channels"] sc --> vic ``` @@ -497,20 +495,20 @@ Keep two to three stable majors supported at the same time, roughly six to twelv `**build` (ubuntu-latest):\*\* -1. `[pnpm exec bun apps/cli/scripts/sync-versions.ts --version X.Y.Z](../scripts/sync-versions.ts)` — writes the release version into every `package.json` (umbrella + eight platform packages) and resolves the umbrella's `workspace:`\* `optionalDependencies` to `X.Y.Z`. -2. `[pnpm exec bun apps/cli/scripts/build.ts --version X.Y.Z](../scripts/build.ts)` — cross-compiles the Bun SFE for all eight targets (including windows-arm64), cross-compiles the Go sidecar, **ad-hoc signs the macOS binaries** (see [Code signing (macOS)](#code-signing-macos)), builds the six Linux packages via `nfpm`, produces the tar/zip archives, and writes `dist/checksums.txt`. +1. `[bun apps/cli/scripts/sync-versions.ts --version X.Y.Z](../scripts/sync-versions.ts)` — writes the release version into every `package.json` (umbrella + eight platform packages) and resolves the umbrella's `workspace:`\* `optionalDependencies` to `X.Y.Z`. +2. `[bun apps/cli/scripts/build.ts --version X.Y.Z](../scripts/build.ts)` — cross-compiles the Bun SFE for all eight targets (including windows-arm64), **ad-hoc signs the macOS binaries** (see [Code signing (macOS)](#code-signing-macos)), builds the six Linux packages via `nfpm`, produces the tar/zip archives, and writes `dist/checksums.txt`. 3. `actions/upload-artifact` preserves `packages/cli-*/bin/` and `dist/` for the downstream jobs. `**smoke-test` (matrix: `ubuntu-latest`, `macos-latest`, `macos-15-intel`, `windows-latest`):\*\* -Downloads the build artifact, makes the SFE executable (`chmod +x` on non-Windows), installs Scoop on Windows, and runs `pnpm run test:smoke -- --version X.Y.Z --tag .stable>` from `apps/cli`. On the macOS legs this also verifies each binary's signature (`codesign --verify --strict`, correct identifier, not linker-signed) and executes `supabase --version`, which is the real AMFI gate. Any failure blocks publishing. +Downloads the build artifact, makes the SFE executable (`chmod +x` on non-Windows), installs Scoop on Windows, and runs `pnpm run test:smoke -- --version X.Y.Z --tag .stable>` from `apps/cli`. On the macOS legs this also verifies the binary's signature (`codesign --verify --strict`, correct identifier, not linker-signed) and executes `supabase --version`, which is the real AMFI gate. Any failure blocks publishing. The matrix does not yet include `windows-11-arm` (gate 6) or an Alpine musl runner (also gate 6). Until those land, arm64 / musl regressions only surface in Ring 2 validation. `**publish` (ubuntu-latest, `if: !inputs.dry_run`):\*\* 1. Re-runs `sync-versions.ts` (download-artifact restores file modes but not JSON mutations). -2. `[pnpm exec bun apps/cli/scripts/publish.ts --tag .stable>](../scripts/publish.ts)` — publishes the eight platform packages in parallel via OIDC trusted publishing (`bun publish --provenance`, no `NPM_TOKEN`), then the umbrella package last so `optionalDependencies` resolve cleanly at install time. +2. `[bun apps/cli/scripts/publish.ts --tag .stable>](../scripts/publish.ts)` — publishes the eight platform packages in parallel via OIDC trusted publishing (`bun publish --provenance`, no `NPM_TOKEN`), then the umbrella package last so `optionalDependencies` resolve cleanly at install time. 3. `softprops/action-gh-release` creates an **empty draft** Release `v` on `supabase/cli`. 4. `[upload-release-assets.ts upload](../scripts/upload-release-assets.ts)` uploads the archives, packages, `checksums.txt`, unversioned aliases, and `install` script **one asset at a time** with `gh release upload --clobber`, up to three attempts each with a five-minute timeout. `gh` itself retries 5xx responses and dropped connections three times within a second; the outer loop covers longer stalls and the `--clobber` delete, which `gh` does not retry. `uploads.github.com` fails single uploads often enough that this is routine: in September 2026 one beta release hit a multi-hour backend degradation and another lost one connection on a healthy backend. The asset list, retry policy, and timeout handling are covered by the unit and integration tests next to the script. 5. `upload-release-assets.ts verify` compares `gh release view --json assets` with the expected asset names. The job fails if any asset is missing or not `uploaded`, so a partial set is never published. @@ -531,14 +529,14 @@ Both updaters run automatically from `release-shared.yml`'s `publish-homebrew` a Once the channels are live, two reusable workflows run automatically (last in `release-shared.yml`, non-gating — by the time they run the artifacts are already published, so a failure surfaces as a red post-release signal rather than blocking distribution): -- `[setup-cli-smoke-test.yml](../../../.github/workflows/setup-cli-smoke-test.yml)` (`setup-cli-smoke` job) — installs the released version through `supabase/setup-cli` (the GitHub Release download path) on Linux, macOS, Windows, and Alpine. -- `[verify-install-channels.yml](../../../.github/workflows/verify-install-channels.yml)` (`verify-install-channels` job) — runs a **real** `brew install` (macOS **and** Linux, so both the `on_macos` and `on_linux` stanzas of the formula are exercised), `scoop install`, and `curl|bash` install of the **published** install script (fetched from the release asset, not the repo checkout) against the just-published Homebrew tap, Scoop bucket, and GitHub Release. Each leg then asserts `supabase --version` matches and runs `supabase completion bash` (a Go-proxied command) so a package that omits or misplaces the `supabase-go` sidecar fails too. brew, scoop, and the install script each verify the published `sha256`/`hash` against the downloaded tarball, so this is the signal that would have caught CLI v2.107.0 (where the brew/scoop manifests shipped checksums that did not match the release tarballs and every `brew install` / `scoop install` failed). It only runs for `beta`/`stable`/`maintenance` (the channels that publish brew/scoop) and can be dispatched manually against any already-published version via the Actions tab. +- `[setup-cli-smoke-test.yml](../../../.github/workflows/setup-cli-smoke-test.yml)` (`setup-cli-smoke` job) — installs the released version through `supabase/setup-cli` v1 and v2 (the GitHub Release download path) and v3 (the npm package) on Linux, macOS, Windows, and Alpine. +- `[verify-install-channels.yml](../../../.github/workflows/verify-install-channels.yml)` (`verify-install-channels` job) — runs a **real** `brew install` (macOS **and** Linux, so both the `on_macos` and `on_linux` stanzas of the formula are exercised), `scoop install`, and `curl|bash` install of the **published** install script (fetched from the release asset, not the repo checkout) against the just-published Homebrew tap, Scoop bucket, and GitHub Release. Each leg then asserts `supabase --version` matches and runs `supabase --help` to exercise the actual command tree. brew, scoop, and the install script each verify the published `sha256`/`hash` against the downloaded tarball, so this is the signal that would have caught CLI v2.107.0 (where the brew/scoop manifests shipped checksums that did not match the release tarballs and every `brew install` / `scoop install` failed). It only runs for `beta`/`stable`/`maintenance` (the channels that publish brew/scoop) and can be dispatched manually against any already-published version via the Actions tab. ### Code signing (macOS) -The macOS binaries (`supabase` Bun SFE + `supabase-go` sidecar, `darwin-arm64` and `darwin-x64`) are signed inside `build.ts` between compilation and archiving, so the signed bytes flow into every channel that consumes `packages/cli-darwin-*/bin/` — npm platform packages, Homebrew, and the GitHub Release tarballs (which also feed the `install` script and `setup-cli`). Background: [ADR 0014](../../../docs/adr/0014-macos-code-signing-and-notarization.md). +The macOS binary (`supabase` Bun SFE, `darwin-arm64` and `darwin-x64`) is signed inside `build.ts` between compilation and archiving, so the signed bytes flow into every channel that consumes `packages/cli-darwin-*/bin/` — npm platform packages, Homebrew, and the GitHub Release tarballs (which also feed the `install` script and `setup-cli`). Background: [ADR 0014](../../../docs/adr/0014-macos-code-signing-and-notarization.md). -Why this exists: `bun build --compile` and the Go linker emit only a degenerate "linker-signed" ad-hoc signature (identifier `a.out`, no requirements blob). macOS 26+ AMFI rejects it and SIGKILLs the process at launch ([CLI-1621](https://linear.app/supabase/issue/CLI-1621) / [#5556](https://github.com/supabase/cli/issues/5556)). A full ad-hoc signature fixes it. +Why this exists: `bun build --compile` emits only a degenerate "linker-signed" ad-hoc signature (identifier `a.out`, no requirements blob). macOS 26+ AMFI rejects it and SIGKILLs the process at launch ([CLI-1621](https://linear.app/supabase/issue/CLI-1621) / [#5556](https://github.com/supabase/cli/issues/5556)). A full ad-hoc signature fixes it. - **Signing runs on the Linux build runner** via [`rcodesign`](https://github.com/indygreg/apple-platform-rs) (the apple-codesign project), which signs Mach-O binaries without a macOS host. No macOS signing job exists, and **no Apple credentials are required for the current ad-hoc signing** (Phase 1). The version + sha256 are pinned in the "Install rcodesign" step of [`build-cli-artifacts.yml`](../../../.github/workflows/build-cli-artifacts.yml). - **CI hard-fails if signing is unavailable**: the build job sets `SUPABASE_CLI_REQUIRE_SIGNING=1`, so a missing `rcodesign` fails the build rather than silently shipping unsigned binaries. Local builds without `rcodesign` degrade to a warning and skip signing. @@ -585,7 +583,7 @@ There is no rollback. Published artifacts are immutable, and repointing npm `lat - [ADR 0011](../../../docs/adr/0011-cli-release-and-distribution-strategy.md) — the decision record. Channel choices, signing rationale, open pre-cutover gates. - [ADR 0028](../../../docs/adr/0028-release-branches-and-maintenance-lines.md) — the `next` channel and `v.x` maintenance lines. - [ADR 0030](../../../docs/adr/0030-hotfix-releases.md) — hotfix criteria, forward-only releases, and the required `Check deploy`. -- `[apps/cli/docs/binary-distribution.md](./binary-distribution.md)` — why each platform package contains two binaries (`supabase` SFE + `supabase-go` sidecar) and how they're resolved at runtime. +- `[apps/cli/docs/binary-distribution.md](./binary-distribution.md)` — how each platform package's `supabase` binary is built and resolved at runtime. - `[tools/release/local-release.ts](../../../tools/release/local-release.ts)` — Ring 1 implementation. - `[apps/cli/scripts/build.ts](../scripts/build.ts)`, `[publish.ts](../scripts/publish.ts)`, `[sync-versions.ts](../scripts/sync-versions.ts)`, `[update-homebrew.ts](../scripts/update-homebrew.ts)`, `[update-scoop.ts](../scripts/update-scoop.ts)` — release script implementations. - `[.github/workflows/release.yml](../../../.github/workflows/release.yml)`, `[release-shared.yml](../../../.github/workflows/release-shared.yml)`, `[deploy.yml](../../../.github/workflows/deploy.yml)`, `[deploy-check.yml](../../../.github/workflows/deploy-check.yml)`, `[fast-forward.yml](../../../.github/workflows/fast-forward.yml)`, `[sync-branches.yml](../../../.github/workflows/sync-branches.yml)`, `[branch-policy.yml](../../../.github/workflows/branch-policy.yml)` — Ring 3 pipeline and branch promotion. diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 46fa0e8a17..e26d1f5ed9 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -139,10 +139,9 @@ are quoted so that sourcing the file performs no shell expansion. The exported v `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and `SERVICE_ROLE_KEY`; `REST_URL` and `FUNCTIONS_URL` are `/rest/v1` and `/functions/v1`, `MCP_URL` is `/mcp`, whose port is assigned per project, and `DB_URL` uses the `postgres` role with the saved database -password, URI-encoded, and no query string. `INBUCKET_URL` is also exported alongside -`MAILPIT_URL`, with the same value, as a deprecated alias. `start` and `status` text output shows -only the publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` -and `status --env`. `start` and `status` JSON/stream-JSON results include this same connection map +password, URI-encoded, and no query string. `start` and `status` text output shows only the +publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` and +`status --env`. `start` and `status` JSON/stream-JSON results include this same connection map under `env`; `status --env` remains the dotenv/variable-map export, and `--override-name` only applies there. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. The @@ -190,9 +189,9 @@ unavailable. An invalid `SUPABASE_EXPERIMENTAL_STACK` value is still an error. For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack`; an unset or empty value falls back to the file setting. -Other values are rejected, including by `supabase link`, `supabase unlink` and `supabase projects -delete`, which read or write the project link. The override is applied before reading the project -configuration. +Non-boolean values are rejected, including by `supabase link`, `supabase unlink` and `supabase +projects delete`, which read or write the project link. The override is applied before reading the +project configuration. `supabase services` follows the same backend selection. In stack mode it lists image versions and canonical `ghcr.io/supabase/cli/...` names, including Mailpit. The versions come from @@ -212,16 +211,16 @@ project config and omits the Docker-era default ports so the stack is not pinned Without the environment variable, `init` still writes the established template with those ports and without the stack flag. Blank `supabase bootstrap` uses the same scaffold. -When the flag is on, `--local` targets of the `db`, `migration`, `test db`, `gen types`, and +When the flag is on, local targets of the `db`, `migration`, `test db`, `gen types`, and `inspect` families use the project stack and provision a throwaway shadow database owned by the stack runtime. The shadow runs the project's resolved database version, the OrioleDB build when `db.orioledb_version` is set, and its baseline cache is keyed by that version. Top-level -`supabase pull` uses the same stack shadow as `db pull`. Linked and `--db-url` targets stay on the Management API for engine selection. -A `--db-url` that matches `config.toml` host and port is still rewritten like a published +`supabase pull` uses the same stack shadow as `db pull`. Targets selected with `--remote` stay on the Management API for engine selection. +A `--remote=` database URL that matches `config.toml` host and port is still rewritten like a published stack target for dump's tool container. Compose names (`supabase_db_*`, `supabase_network_*`, `db:5432`) are not used. The stack backend requires the in-process pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `db pull --diff-engine migra` are -rejected. The flag also routes local `functions serve` through the stack; other `functions` +rejected (`--use-migra=false` and `--use-pgadmin=false` keep pg-delta and are accepted). The flag also routes local `functions serve` through the stack; other `functions` commands retain their existing behavior. `functions serve` requires a running project stack. It attaches to the existing Functions member, @@ -244,34 +243,33 @@ and restores default JWT verification on the same Functions instance. Per-functi `--output-format stream-json`, readiness emits a result containing the instance ID and URL, followed by live log events. -`storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including bucket seeding inside `db reset ---local`) also consult `experimental.stack`, with the same `SUPABASE_EXPERIMENTAL_STACK` +`storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including bucket seeding inside `db reset`) also consult `experimental.stack`, with the same `SUPABASE_EXPERIMENTAL_STACK` env-precedence rule as `start`/`stop`/`status`. See -[Storage and bucket seeding](#storage-and-bucket-seeding) below. Explicit `--linked`/ -`--project-ref` remote targeting for these commands is unaffected by the flag either way. +[Storage and bucket seeding](#storage-and-bucket-seeding) below. Explicit `--remote` +targeting for these commands is unaffected by the flag either way. `db start` brings up a postgres-only project stack on first create. An existing stack resumes its primary database without changing other services (webhooks setup only, creating a missing `supabase_functions` schema; no second overlay or migrate-and-seed). `supabase start` while that postgres-only stack is running stops it and starts the full configured stack, keeping data. `--from-backup` is not supported on the stack path. -`db reset --local` and declarative resets rebuild the existing database while retaining stack +A local `db reset` and declarative resets rebuild the existing database while retaining stack identity, ports, and composition. See [`db/reset/SIDE_EFFECTS.md`](../src/commands/db/reset/SIDE_EFFECTS.md). -`gen types --local` and `inspect db … --local` resolve the project stack through the same -`--local` database target as `db dump`. They do not start a stack. +A local `gen types` and `inspect db …` resolve the project stack through the same +local database target as `db dump`. They do not start a stack. -On the stack backend, `db dump --local`, `migration squash`, and `test db --local` run +On the stack backend, a local `db dump`, `migration squash`, and a local `test db` run catalog tools attached to their owning stack. Tools use runtime-facing database addresses; `test db` preserves the user, password, and database selected by the CLI resolver. -External `--linked` and `--db-url` tools use CLI-owned adapters without creating a stack. +External `--remote` tools use CLI-owned adapters without creating a stack. Supported native platforms run the bundled executable with `artifact/bin` on PATH. Other platforms use a one-shot container from the catalog Postgres image. Neither path falls back to host-installed PostgreSQL tools. Missing tools fail closed; container execution requires Docker. -`db lint --local` talks to the running stack over SQL. Catalog Postgres includes +A local `db lint` talks to the running stack over SQL. Catalog Postgres includes `plpgsql_check`, so native and container stacks can `CREATE EXTENSION` inside the lint transaction (always rolled back). It does not launch a client binary. @@ -351,7 +349,7 @@ start --frozen` fails instead of resolving when the lock is missing or stale; it targets another project or branch than the lock observed (run `supabase stack update`), and when this CLI bundles a newer revision of a locked version. A lock that is malformed, does not match the format, or comes from a newer CLI always fails the start. The lock's JSON Schema is published at -`https://supabase.com/docs/cli/supabase-lock/v1.schema.json`. A database with initialized data keeps +`https://supabase.com/schemas/supabase-lock/v1.json`. A database with initialized data keeps its build: any change other than a new `-rN` revision of the same build (a different upstream build, release line or major) is refused. The refusal offers to keep the data by pinning the recorded build with `[local.services.database] version`, setting `[db] major_version` back when the major differs and removing any @@ -411,12 +409,12 @@ to be destroyed does not stop the rest, though the command still fails. The `experimental.stack` flag (with the usual `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence) routes several command families at once: the top-level `start`/`stop`/`status` -aliases, the `stack` namespace itself, `db`/`migration`/`test`'s `--local` targets (above), and +aliases, the `stack` namespace itself, `db`/`migration`/`test`'s local targets (above), and now `storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including the bucket seeding step inside `db -reset --local`). An explicit `--linked`/`--project-ref` remote target for `storage`/`seed` is +reset`). An explicit `--remote` target for `storage`/`seed` is unaffected by the flag either way. -Under the stack backend, `--local` Storage operations resolve their endpoint and credential from +Under the stack backend, local Storage operations resolve their endpoint and credential from the selected composition rather than `[api]`/`[api.tls]`: the endpoint is the Storage member's observed HTTP binding plus `/storage/v1/...`, and the service-role JWT is generated from the primary database member's observed JWT secret. The stack is located by the project root @@ -444,7 +442,7 @@ status`/`supabase stack restart`, or `supabase start` when the stack has never b Missing Storage HTTP endpoints fail with `StackStorageCapabilityError`, as do other Storage capability failures. A stack-gateway 502/503 encountered while Storage activates is reported as `StackStorageCapabilityError` guiding the user to `supabase stack logs` then `supabase stack -restart`, not as a raw status body — but only for a `--local` target; a `--linked` failure passes +restart`, not as a raw status body — but only for a local target; a `--remote` failure passes through unchanged. All of these failures exit `1` for `storage`/`seed buckets`, and no HTTP request is sent when Storage is disabled or the stack is not running. @@ -466,6 +464,6 @@ a missing capability/endpoint, a gateway activation failure, or an invalid bucke `start` command; startup does not silently skip the configured seed. See [`stack/start/SIDE_EFFECTS.md`](../src/commands/experimental/stack/start/SIDE_EFFECTS.md). -**`db reset --local` and declarative reset** reapply the catalog, migrations, and seeds before +**A local `db reset` and declarative reset** reapply the catalog, migrations, and seeds before resuming the saved composition. Bucket-seeding failures warn after the database reset succeeds. See [`db/reset/SIDE_EFFECTS.md`](../src/commands/db/reset/SIDE_EFFECTS.md). diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index 6d4a6b9e46..57e51097b8 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -25,7 +25,7 @@ User-authored configuration and migrations live in the repository: functions/ ``` -The CLI discovers the active project from the nearest ancestor containing `supabase/config.toml` +The CLI discovers the active project from the nearest ancestor containing `supabase/config.toml` or `supabase/config.json` (or the legacy config filename where supported), then passes that canonical project root to the managed stack identity. A stack ID is derived from the canonical project root, the current Git branch context (or `ordinary-workspace` outside Git), and the stack name. This identity is local diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md index 229783224c..6f299df3ef 100644 --- a/apps/cli/docs/supabase/config/diff.md +++ b/apps/cli/docs/supabase/config/diff.md @@ -2,7 +2,7 @@ Shows the configuration differences between the local `supabase/config.toml` and the effective configuration of a remote project or branch. Read-only: it never modifies the local file or any remote configuration. -Pass `--project-ref` to compare against a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. +Pass `--remote=` to compare against a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. Only platform-managed properties are compared. Local-stack-only sections — `[studio]`, `[analytics]`, `[functions]`, `[edge_runtime]`, port numbers, image version pins, `[db.migrations]`, and similar — have no hosted counterpart and are never reported, whether or not your file declares them. @@ -12,4 +12,4 @@ Local values are shown as the configuration your file would produce once pushed, With `--exit-code`, the command exits `2` when any difference is found, keeping exit `1` for errors — so scripts can distinguish drift from failure. -Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. `target` identifies the compared project: `kind` (`linked`), `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, `branch` when it was named, and `local_scope` (`base` or `remotes.`). The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index 3e238c1d59..0b7878c0b5 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -2,9 +2,9 @@ Writes the effective configuration of a remote project or branch into the local `supabase/config.toml`/`config.json` — the write side of `supabase config diff`. Every write is a surgical, format-preserving edit: comments, key ordering, and quoting elsewhere in the file are left untouched, and only the values that actually change are rewritten. -Pass `--project-ref` to pull from a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. +Pass `--remote=` to pull from a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. -Where a pulled value lands depends on whether the target is already tracked by a `[remotes.*]` block, not on how you named it: if any block's `project_id` already matches the resolved ref, that block is reused — whatever its own label — and every value lands there. Otherwise, if the target was named as a branch, a new `[remotes.]` block is created (a branch resolved by UUID falls back to the project ref itself as its label). Only when neither applies — a bare `--project-ref` naming a project directly, or the linked project with no branch involved — do values land at the config root. `--remote-label` overrides the block config pull would otherwise reuse or create; naming a block that already tracks a different project, or naming nothing while a different block already tracks this exact ref, is an error rather than a silent overwrite. A `[remotes.*]` block whose `project_id` is an `env(...)` reference that merely _resolves_ to the target ref is never reused or rewritten — that is a hard error naming the variable, since a value written there would never actually take effect on the next load. Creating a new block always writes its `project_id`, even when every value it would otherwise carry already matches the local defaults — otherwise the block could never be reused on a later pull. +Where a pulled value lands depends on whether the target is already tracked by a `[remotes.*]` block, not on how you named it: if any block's `project_id` already matches the resolved ref, that block is reused — whatever its own label — and every value lands there. Otherwise, if the target was named as a branch, a new `[remotes.]` block is created (a branch resolved by UUID falls back to the project ref itself as its label). Only when neither applies — `--remote=` naming a project directly, or the linked project (a bare `--remote` or no flag) with no branch involved — do values land at the config root. `--remote-label` overrides the block config pull would otherwise reuse or create; naming a block that already tracks a different project, or naming nothing while a different block already tracks this exact ref, is an error rather than a silent overwrite. A `[remotes.*]` block whose `project_id` is an `env(...)` reference that merely _resolves_ to the target ref is never reused or rewritten — that is a hard error naming the variable, since a value written there would never actually take effect on the next load. Creating a new block always writes its `project_id`, even when every value it would otherwise carry already matches the local defaults — otherwise the block could never be reused on a later pull. Writing to the config root can also affect `supabase start`: a handful of root-scoped settings — `auth.site_url`, `db.settings.*`, `db.major_version` (which changes what `supabase start` boots), `db.pooler.*`, and similar — also govern the local stack, so pulling a hosted value into them is a real change to local dev behavior, not just a record of the hosted project's own setting. `config pull` warns about this rather than refusing; writing the same value into a `[remotes.*]` block is unaffected. Passing `--remote-label` is the escape hatch: it diverts what would otherwise be a root-bound pull into a `[remotes.

Details: timing ranges, API calls per command, slowest phases", + "#### Timing ranges", + rangeTable(analyses), + "#### API calls per command", + apiDetailTable(analyses), + "#### Slowest phases", + phaseDetail(analyses), + "
", + ].join("\n\n"), + ], + context, + report, + ); +} + +export const loadReport = Effect.fn("BenchSummary.loadReport")(function* ( + fs: FileSystem.FileSystem, + file: string, +) { + if (!(yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)))) { + return { report: undefined, notice: NO_REPORT }; + } + return yield* fs.readFileString(file).pipe( + Effect.flatMap(decodeBenchReport), + Effect.map((report) => ({ report, notice: NO_REPORT })), + Effect.catch((cause) => + Console.error(`could not read benchmark report ${file}: ${String(cause)}`).pipe( + Effect.as({ + report: undefined, + notice: UNREADABLE_REPORT, + }), + ), + ), + ); +}); + +const main = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const argv = yield* stdio.args; + const options = yield* Effect.try({ + try: () => parseSummaryArgs(argv), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe( + Effect.catch((message) => + Console.error(message).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 2; + }), + ), + Effect.as(undefined), + ), + ), + ); + if (options === undefined) return; + + const fs = yield* FileSystem.FileSystem; + const outcomeNotice = summaryNotice(options); + const loaded = + outcomeNotice === undefined && options.report !== undefined + ? yield* loadReport(fs, options.report) + : { report: undefined, notice: outcomeNotice ?? NO_REPORT }; + const context = { + headSha: options.headSha, + baseRef: options.baseRef, + mergeBase: options.mergeBase, + }; + const markdown = renderSummary(loaded.report, loaded.notice, { target: "summary", ...context }); + const summaryFile = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY")); + if (Option.isSome(summaryFile) && summaryFile.value !== "") { + yield* fs.writeFileString(summaryFile.value, markdown, { flag: "a" }); + } else { + yield* Console.log(markdown); + } + + if (options.commentOut !== undefined) { + yield* fs.writeFileString( + options.commentOut, + renderSummary(loaded.report, loaded.notice, { target: "comment", ...context }), + ); + } +}); + +if (import.meta.main) { + BunRuntime.runMain(main.pipe(Effect.provide(BunServices.layer))); +} diff --git a/apps/cli/scripts/bench-summary.unit.test.ts b/apps/cli/scripts/bench-summary.unit.test.ts new file mode 100644 index 0000000000..3d5e369c6a --- /dev/null +++ b/apps/cli/scripts/bench-summary.unit.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import type { BenchReport, RunSample } from "./bench-report.ts"; +import { BENCH_MARKER, parseSummaryArgs, renderSummary, summaryNotice } from "./bench-summary.ts"; + +const run = (wallMs: number, calls: number, endpoints: Record): RunSample => ({ + wallMs, + exitCode: 0, + tracedExitCode: 0, + trace: { + skippedLines: 0, + spans: { "cli.run": { totalMs: wallMs / 2, count: 1 } }, + api: { apiCalls: calls, externalCalls: 0, errorStatuses: 0, endpoints, requestLogCount: calls }, + }, +}); + +const runs = (n: number, make: (i: number) => RunSample) => + Array.from({ length: n }, (_, i) => make(i)); + +const report = (workloads: BenchReport["workloads"]): BenchReport => ({ + base: "/base/supabase", + branch: "/branch/supabase", + baseSizeBytes: 1000, + branchSizeBytes: 1500, + runs: 5, + warmup: 1, + trace: true, + apiUrl: "http://127.0.0.1:1", + workloads, +}); + +const steady = (id: string, args: ReadonlyArray) => ({ + id, + args, + base: runs(5, (i) => run(30 + i, 0, {})), + branch: runs(5, (i) => run(30 + i, 0, {})), +}); + +const migrationNew = { + id: "migration-new", + args: ["migration", "new", "bench"], + base: runs(5, (i) => run(50 + i, 0, {})), + branch: runs(5, (i) => run(120 + i, 1, { "GET /v1/projects/abc": 1 })), +}; + +describe("renderSummary", () => { + it("leads with no changes found and shows commands, not workload ids", () => { + const sized = { + ...report([steady("version", ["--version"])]), + baseSizeBytes: 140269744, + branchSizeBytes: 140269744, + }; + const markdown = renderSummary(sized, "unused", { + target: "summary", + headSha: "abcdef1234567890", + baseRef: "develop", + mergeBase: "1234567890abcdef", + }); + + expect(markdown).toContain("## CLI benchmark: no changes found"); + expect(markdown).toContain( + "Commit `abcdef1` against `develop` at `1234567` · 5 runs each · report only, never blocks merging", + ); + expect(markdown).toContain("| `supabase --version` | 32 ms | 32 ms | no real change |"); + expect(markdown).toContain("API calls: unchanged for the command."); + expect(markdown).toContain("Binary size: 133.8 MB, unchanged."); + expect(markdown).not.toContain("⚠️"); + }); + + it("shows a binary size change in MB, or KB when it is small", () => { + const grown = { + ...report([steady("version", ["--version"])]), + baseSizeBytes: 140269744, + branchSizeBytes: 140269744 + 3 * 1048576, + }; + const nudged = { ...grown, branchSizeBytes: 140269744 + 2048 }; + + expect(renderSummary(grown, "unused")).toContain( + "Binary size: 136.8 MB, up 3.0 MB from 133.8 MB.", + ); + expect(renderSummary(nudged, "unused")).toContain("up 2.0 KB from 133.8 MB"); + }); + + it("explains a new API call in plain words and counts each command once", () => { + const markdown = renderSummary(report([migrationNew]), "unused"); + + expect(markdown).toContain("## CLI benchmark: 1 thing to check"); + expect(markdown).toContain( + "⚠️ `supabase migration new bench` now calls the Management API. It made no calls before and now makes 1 (`GET /v1/projects/abc`).", + ); + expect(markdown).toContain("slower by 70 ms (+135%)"); + }); + + it("keeps command text from the benchmarked build inside code spans", () => { + const markdown = renderSummary(report([steady("x", ["a`@someone`", "b|c"])]), "unused"); + + expect(markdown).toContain("`supabase a'@someone' b\\|c`"); + }); + + it("says nothing was benchmarked instead of a clean pass when there are no commands", () => { + const markdown = renderSummary(report([]), "unused"); + + expect(markdown).toContain("## CLI benchmark: no commands were benchmarked"); + expect(markdown).not.toContain("no changes found"); + }); + + it("describes a dropped API call with a singular count", () => { + const dropped = { + id: "list", + args: ["projects", "list"], + base: runs(5, (i) => run(30 + i, 1, { "GET /v1/projects": 1 })), + branch: runs(5, (i) => run(30 + i, 0, {})), + }; + + const markdown = renderSummary(report([dropped]), "unused"); + + expect(markdown).toContain( + "⚠️ `supabase projects list` no longer calls the Management API. It made 1 call before and now makes none.", + ); + }); + + it("names the failed step and stays a notice when there is no report", () => { + const markdown = renderSummary(undefined, "The build of this commit failed.", { + target: "summary", + }); + + expect(markdown).toContain("## CLI benchmark: could not run"); + expect(markdown).toContain("The build of this commit failed."); + expect(markdown).not.toContain("/bench"); + }); + + it("adds the marker, re-run hint only to the comment", () => { + const input = report([steady("version", ["--version"])]); + const context = { headSha: "abcdef1234" } as const; + + const comment = renderSummary(input, "unused", { target: "comment", ...context }); + const summary = renderSummary(input, "unused", { target: "summary", ...context }); + + expect(BENCH_MARKER).toBe(""); + expect(comment.startsWith(`${BENCH_MARKER}\n`)).toBe(true); + expect(comment).toContain("Comment `/bench` to run it again on the latest commit."); + expect(summary).not.toContain(BENCH_MARKER); + expect(summary).not.toContain("/bench"); + }); + + it("does not claim a missing timing verdict when commands were not comparable", () => { + const failing = (wallMs: number): RunSample => ({ ...run(wallMs, 0, {}), exitCode: 1 }); + + const markdown = renderSummary( + report([ + { + id: "broken", + args: ["x"], + base: runs(5, (i) => run(30 + i, 0, {})), + branch: runs(5, (i) => failing(30 + i)), + }, + ]), + "unused", + ); + + expect(markdown).toContain("No phase deltas are available; some commands were not comparable."); + expect(markdown).toContain("not compared"); + }); + + it("says API calls were not counted when no call data exists", () => { + const noTrace = (wallMs: number): RunSample => ({ wallMs, exitCode: 0 }); + const markdown = renderSummary( + { + ...report([ + { + id: "a", + args: ["x"], + base: runs(5, (i) => noTrace(30 + i)), + branch: runs(5, (i) => noTrace(30 + i)), + }, + ]), + trace: false, + }, + "unused", + ); + + expect(markdown).toContain("API calls were not counted."); + }); +}); + +describe("summaryNotice", () => { + const ok = { branchBuild: "ok", baseBuild: "ok", replay: "ok" } as const; + + it("is undefined when every step succeeded", () => { + expect(summaryNotice(ok)).toBeUndefined(); + }); + + it.each([ + { failed: { branchBuild: "failed" }, expected: "build of this commit failed" }, + { failed: { baseBuild: "failed" }, expected: "build of the base branch failed" }, + { failed: { replay: "failed" }, expected: "test server failed to start" }, + ] as const)("names the failed step: $expected", ({ failed, expected }) => { + expect(summaryNotice({ ...ok, ...failed })).toContain(expected); + }); + + it("reports the earliest failed step when later ones were skipped", () => { + expect( + summaryNotice({ branchBuild: "failed", baseBuild: "failed", replay: "failed" }), + ).toContain("build of this commit failed"); + }); +}); + +describe("parseSummaryArgs", () => { + it("rejects an unknown step outcome", () => { + expect(() => parseSummaryArgs(["--report", "r.json", "--replay", "maybe"])).toThrow("--replay"); + }); + + it("needs a report unless a step failed", () => { + expect(() => parseSummaryArgs([])).toThrow(); + expect(parseSummaryArgs(["--replay", "failed"])).toMatchObject({ + report: undefined, + branchBuild: "ok", + baseBuild: "ok", + replay: "failed", + }); + expect( + parseSummaryArgs(["--report", "r.json", "--head-sha", "abc", "--comment-out", "c.md"]), + ).toMatchObject({ report: "r.json", replay: "ok", headSha: "abc", commentOut: "c.md" }); + }); +}); diff --git a/apps/cli/scripts/bench-workloads.unit.test.ts b/apps/cli/scripts/bench-workloads.unit.test.ts new file mode 100644 index 0000000000..403ce9664f --- /dev/null +++ b/apps/cli/scripts/bench-workloads.unit.test.ts @@ -0,0 +1,39 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { parseWorkloadManifest, validateWorkloads } from "./bench-report.ts"; + +const manifestPath = new URL("../bench/workloads.pr.json", import.meta.url).pathname; +const scenariosDir = new URL("../../cli-e2e/fixtures/scenarios/", import.meta.url).pathname; + +const loadManifest = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* parseWorkloadManifest(yield* fs.readFileString(manifestPath)); +}); + +describe("PR workload manifest", () => { + it.effect("decodes and passes the structural rules when a replay server is configured", () => + Effect.gen(function* () { + const workloads = yield* loadManifest; + + expect(workloads.length).toBeGreaterThan(0); + expect(validateWorkloads(workloads, { apiUrl: "http://127.0.0.1:1" })).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("names only replay scenarios that exist and serve the project ref it uses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workloads = yield* loadManifest; + + for (const workload of workloads) { + if (workload.scenario === undefined) continue; + const fixture = yield* fs.readFileString( + `${scenariosDir}${workload.scenario}/interactions.json`, + ); + const ref = workload.args.find((arg) => arg.startsWith("replay")); + if (ref !== undefined) expect(fixture).toContain(`/v1/projects/${ref}`); + } + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts index 8073bee193..f3ee9dad24 100644 --- a/apps/cli/scripts/build-binary.integration.test.ts +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -1,7 +1,6 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { fileURLToPath } from "node:url"; const fixturePath = fileURLToPath( @@ -10,89 +9,111 @@ const fixturePath = fileURLToPath( const versionFixturePath = fileURLToPath( new URL("../tests/fixtures/compiled-cli-version.ts", import.meta.url), ); -const temporaryDirectories: string[] = []; +const platformLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), - ); -}); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); describe("compiled binary assets", () => { - test("embeds and loads libpg-query.wasm", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-wasm-")); - temporaryDirectories.push(directory); - const executable = path.join(directory, "parser-probe"); - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) { - throw new Error("Bun executable not found"); - } + it.live( + "embeds and loads libpg-query.wasm", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-compiled-wasm-" }); + const executable = path.join(directory, "parser-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } - const build = Bun.spawn( - [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], - { stdout: "pipe", stderr: "pipe" }, - ); - const [buildExitCode, buildStderr] = await Promise.all([ - build.exited, - new Response(build.stderr).text(), - ]); - expect(buildExitCode, buildStderr).toBe(0); + const build = Bun.spawn( + [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = yield* Effect.all( + [ + Effect.promise(() => build.exited), + Effect.promise(() => new Response(build.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + expect(buildExitCode, buildStderr).toBe(0); - const probe = Bun.spawn([executable], { - cwd: directory, - env: {}, - stdout: "pipe", - stderr: "pipe", - }); - const [probeExitCode, stdout, stderr] = await Promise.all([ - probe.exited, - new Response(probe.stdout).text(), - new Response(probe.stderr).text(), - ]); + const probe = Bun.spawn([executable], { + cwd: directory, + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => probe.exited), + Effect.promise(() => new Response(probe.stdout).text()), + Effect.promise(() => new Response(probe.stderr).text()), + ], + { concurrency: "unbounded" }, + ); - expect(probeExitCode, stderr).toBe(0); - expect(stdout).toContain("libpg-query.wasm loaded"); - }, 20_000); + expect(probeExitCode, stderr).toBe(0); + expect(stdout).toContain("libpg-query.wasm loaded"); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); - test("embeds the build version independently of the runtime environment", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-version-")); - temporaryDirectories.push(directory); - const executable = path.join(directory, "version-probe"); - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) { - throw new Error("Bun executable not found"); - } + it.live( + "embeds the build version independently of the runtime environment", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-compiled-version-", + }); + const executable = path.join(directory, "version-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } - const build = Bun.spawn( - [ - bunExecutable, - "build", - versionFixturePath, - "--compile", - `--define=SUPABASE_CLI_VERSION=${JSON.stringify("7.8.9-beta.1")}`, - `--outfile=${executable}`, - ], - { stdout: "pipe", stderr: "pipe" }, - ); - const [buildExitCode, buildStderr] = await Promise.all([ - build.exited, - new Response(build.stderr).text(), - ]); - expect(buildExitCode, buildStderr).toBe(0); + const build = Bun.spawn( + [ + bunExecutable, + "build", + versionFixturePath, + "--compile", + `--define=SUPABASE_CLI_VERSION=${yield* encodeJson("7.8.9-beta.1")}`, + `--outfile=${executable}`, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = yield* Effect.all( + [ + Effect.promise(() => build.exited), + Effect.promise(() => new Response(build.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + expect(buildExitCode, buildStderr).toBe(0); - const probe = Bun.spawn([executable], { - cwd: directory, - env: { SUPABASE_CLI_VERSION: "9.9.9" }, - stdout: "pipe", - stderr: "pipe", - }); - const [probeExitCode, stdout, stderr] = await Promise.all([ - probe.exited, - new Response(probe.stdout).text(), - new Response(probe.stderr).text(), - ]); + const probe = Bun.spawn([executable], { + cwd: directory, + env: { SUPABASE_CLI_VERSION: "9.9.9" }, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => probe.exited), + Effect.promise(() => new Response(probe.stdout).text()), + Effect.promise(() => new Response(probe.stderr).text()), + ], + { concurrency: "unbounded" }, + ); - expect(probeExitCode, stderr).toBe(0); - expect(stdout.trim()).toBe("7.8.9-beta.1"); - }, 20_000); + expect(probeExitCode, stderr).toBe(0); + expect(stdout.trim()).toBe("7.8.9-beta.1"); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); }); diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 28ed04fee9..a92ca1d242 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Console, Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; @@ -32,5 +32,5 @@ const result = await Bun.build({ }, }); for (const log of result.logs) { - console.warn(log); + Effect.runSync(Console.warn(log)); } diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 52ee98efff..380c5c3655 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -1,10 +1,9 @@ import { $ } from "bun"; import { createHash } from "node:crypto"; -import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { Effect } from "effect"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Config, Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -30,373 +29,356 @@ const { values } = parseArgs({ }, }); -const root = path.resolve(import.meta.dir, "../../.."); -const packageJsonPath = path.join(root, "apps/cli/package.json"); -const packageVersion = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string }; -const version = values.version ?? packageVersion.version; -if (!version) { - console.error("Usage: pnpm exec bun apps/cli/scripts/build.ts [--version ]"); - process.exit(1); -} -if (values.version === undefined) { - console.warn( - `[build] --version not provided; falling back to package.json version "${version}". Pass --version explicitly in release builds.`, - ); -} +class BuildStepFailed extends Data.TaggedError("BuildStepFailed")<{ readonly cause: unknown }> {} -const TARGETS = [ - { - bunTarget: "bun-darwin-arm64", - pkg: "cli-darwin-arm64", - archive: `supabase_${version}_darwin_arm64.tar.gz`, - ext: "", - }, - { - bunTarget: "bun-darwin-x64", - pkg: "cli-darwin-x64", - archive: `supabase_${version}_darwin_amd64.tar.gz`, - ext: "", - }, - { - bunTarget: "bun-linux-arm64", - pkg: "cli-linux-arm64", - archive: `supabase_${version}_linux_arm64.tar.gz`, - nfpmArch: "arm64", - ext: "", - }, - { - bunTarget: "bun-linux-x64-baseline", - pkg: "cli-linux-x64", - archive: `supabase_${version}_linux_amd64.tar.gz`, - nfpmArch: "amd64", - ext: "", - }, - { - bunTarget: "bun-windows-x64-baseline", - pkg: "cli-windows-x64", - archive: `supabase_${version}_windows_amd64.zip`, - ext: ".exe", - }, - { - bunTarget: "bun-windows-arm64", - pkg: "cli-windows-arm64", - archive: `supabase_${version}_windows_arm64.zip`, - ext: ".exe", - }, -] as const; - -const entrypoint = path.join(root, "apps/cli/src/main.ts"); -const distDir = path.join(root, "dist"); -const goSource = path.resolve(root, "apps/cli-go"); -const buildDefines = { - ...(await stackReleaseDefine()), - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleServeMainTemplate()), - ), - SUPABASE_CLI_POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), - SUPABASE_CLI_POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), - // Skips msgpackr's startup probe for its native addon at the build host's store path, which - // on macOS goes through the automounter and can hang every command (supabase/cli#6771). - "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), -}; - -type BunTarget = (typeof TARGETS)[number]["bunTarget"]; - -const GO_TARGETS: Record = { - "bun-darwin-arm64": { goos: "darwin", goarch: "arm64" }, - "bun-darwin-x64": { goos: "darwin", goarch: "amd64" }, - "bun-linux-arm64": { goos: "linux", goarch: "arm64" }, - "bun-linux-x64-baseline": { goos: "linux", goarch: "amd64" }, - "bun-windows-x64-baseline": { goos: "windows", goarch: "amd64" }, - "bun-windows-arm64": { goos: "windows", goarch: "arm64" }, -}; - -type SignMode = "adhoc" | "off"; - -async function runBunBuild(config: Bun.BuildConfig) { - const result = await Bun.build({ - ...config, - ...compileOptions, - plugins: config.plugins ?? [], - }); - for (const log of result.logs) { - console.warn(log); - } -} - -async function buildTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new BuildStepFailed({ cause }) }); - const outfile = path.join(binDir, `supabase${target.ext}`); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - console.log(`[${target.pkg}] Compiling Bun CLI...`); - await runBunBuild({ - entrypoints: [entrypoint], - compile: { target: target.bunTarget, outfile }, - define: { - ...buildDefines, - SUPABASE_CLI_VERSION: JSON.stringify(version), - }, - }); - console.log(`[${target.pkg}] Done.`); -} +/* Native `JSON.parse`, so a malformed package.json fails with its own `SyntaxError`. */ +const parsePackageJson = (text: string) => JSON.parse(text) as { version?: string }; -async function buildGoTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); +const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); - const { goos, goarch } = GO_TARGETS[target.bunTarget]; - const outfile = path.join(binDir, `supabase-go${target.ext}`); +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + /* Concurrent jobs run as promises joined by `Promise.all`, so one failed job does not + interrupt the others. */ + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const runJob = ( + job: Effect.Effect, + ) => runPromise(job.pipe(Effect.catch((error) => Effect.die(thrownValue(error))))); - console.log(`[${target.pkg}] Compiling Go CLI (${goos}/${goarch})...`); - const ldflagParts = ["-s", "-w", `-X github.com/supabase/cli/internal/utils.Version=${version}`]; - const { SENTRY_DSN, POSTHOG_API_KEY, POSTHOG_ENDPOINT } = process.env; - if (SENTRY_DSN) { - ldflagParts.push(`-X github.com/supabase/cli/internal/utils.SentryDsn=${SENTRY_DSN}`); - } - if (POSTHOG_API_KEY) { - ldflagParts.push(`-X github.com/supabase/cli/internal/utils.PostHogAPIKey=${POSTHOG_API_KEY}`); + const root = path.resolve(import.meta.dir, "../../.."); + const packageJsonPath = path.join(root, "apps/cli/package.json"); + const packageJsonText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(packageJsonPath), + ); + const packageVersion = yield* Effect.try({ + try: () => parsePackageJson(packageJsonText), + catch: (cause) => new BuildStepFailed({ cause }), + }); + const version = values.version ?? packageVersion.version; + if (!version) { + yield* Console.error("Usage: bun apps/cli/scripts/build.ts [--version ]"); + process.exit(1); } - if (POSTHOG_ENDPOINT) { - ldflagParts.push( - `-X github.com/supabase/cli/internal/utils.PostHogEndpoint=${POSTHOG_ENDPOINT}`, + if (values.version === undefined) { + yield* Console.warn( + `[build] --version not provided; falling back to package.json version "${version}". Pass --version explicitly in release builds.`, ); } - const goLdflags = ldflagParts.join(" "); - await $`go build -trimpath -ldflags=${goLdflags} -o ${outfile} .`.cwd(goSource).env({ - ...process.env, - GOOS: goos, - GOARCH: goarch, - CGO_ENABLED: "0", + + const TARGETS = [ + { + bunTarget: "bun-darwin-arm64", + pkg: "cli-darwin-arm64", + archive: `supabase_${version}_darwin_arm64.tar.gz`, + ext: "", + }, + { + bunTarget: "bun-darwin-x64", + pkg: "cli-darwin-x64", + archive: `supabase_${version}_darwin_amd64.tar.gz`, + ext: "", + }, + { + bunTarget: "bun-linux-arm64", + pkg: "cli-linux-arm64", + archive: `supabase_${version}_linux_arm64.tar.gz`, + nfpmArch: "arm64", + ext: "", + }, + { + bunTarget: "bun-linux-x64-baseline", + pkg: "cli-linux-x64", + archive: `supabase_${version}_linux_amd64.tar.gz`, + nfpmArch: "amd64", + ext: "", + }, + { + bunTarget: "bun-windows-x64-baseline", + pkg: "cli-windows-x64", + archive: `supabase_${version}_windows_amd64.zip`, + ext: ".exe", + }, + { + bunTarget: "bun-windows-arm64", + pkg: "cli-windows-arm64", + archive: `supabase_${version}_windows_arm64.zip`, + ext: ".exe", + }, + ] as const; + + const entrypoint = path.join(root, "apps/cli/src/main.ts"); + const distDir = path.join(root, "dist"); + const buildDefines = { + ...(yield* step(() => stackReleaseDefine())), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: yield* encodeJson(yield* bundleServeMainTemplate()), + SUPABASE_CLI_POSTHOG_KEY: yield* encodeJson(yield* envOrEmpty("POSTHOG_API_KEY")), + SUPABASE_CLI_POSTHOG_HOST: yield* encodeJson(yield* envOrEmpty("POSTHOG_ENDPOINT")), + // Skips msgpackr's startup probe for its native addon at the build host's store path, which + // on macOS goes through the automounter and can hang every command (supabase/cli#6771). + "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": yield* encodeJson("true"), + }; + + type SignMode = "adhoc" | "off"; + + const runBunBuild = Effect.fnUntraced(function* (config: Bun.BuildConfig) { + const result = yield* step(() => + Bun.build({ + ...config, + ...compileOptions, + plugins: config.plugins ?? [], + }), + ); + for (const log of result.logs) { + yield* Console.warn(log); + } }); - console.log(`[${target.pkg}] Go binary done.`); -} -/** - * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing - * runs inline on this build runner; falls back to "off" with a warning unless - * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. - */ -function resolveSignMode(): SignMode { - if (Bun.which("rcodesign")) { - return "adhoc"; - } - if (process.env.SUPABASE_CLI_REQUIRE_SIGNING === "1") { - console.error( - "rcodesign not found but SUPABASE_CLI_REQUIRE_SIGNING=1 is set. Release builds " + - "must sign the macOS binaries. Install rcodesign (https://github.com/indygreg/apple-platform-rs).", + const buildTarget = Effect.fnUntraced(function* (target: (typeof TARGETS)[number]) { + const binDir = path.join(root, "packages", target.pkg, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + const outfile = path.join(binDir, `supabase${target.ext}`); + + yield* Console.log(`[${target.pkg}] Compiling Bun CLI...`); + yield* runBunBuild({ + entrypoints: [entrypoint], + compile: { target: target.bunTarget, outfile }, + define: { + ...buildDefines, + SUPABASE_CLI_VERSION: yield* encodeJson(version), + }, + }); + yield* Console.log(`[${target.pkg}] Done.`); + }); + + /** + * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing + * runs inline on this build runner; falls back to "off" with a warning unless + * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. + */ + const resolveSignMode = Effect.fnUntraced(function* () { + if (Bun.which("rcodesign")) { + return "adhoc"; + } + if ((yield* envOrEmpty("SUPABASE_CLI_REQUIRE_SIGNING")) === "1") { + yield* Console.error( + "rcodesign not found but SUPABASE_CLI_REQUIRE_SIGNING=1 is set. Release builds " + + "must sign the macOS binaries. Install rcodesign (https://github.com/indygreg/apple-platform-rs).", + ); + process.exit(1); + } + yield* Console.warn( + "WARNING: rcodesign not found — macOS binaries will keep Bun's linker-signed ad-hoc " + + "signature, which macOS 26+ SIGKILLs at launch (CLI-1621). Install rcodesign to " + + "produce runnable macOS binaries locally.", ); - process.exit(1); - } - console.warn( - "WARNING: rcodesign not found — macOS binaries will keep Bun's linker-signed ad-hoc " + - "signature, which macOS 26+ SIGKILLs at launch (CLI-1621). Install rcodesign to " + - "produce runnable macOS binaries locally.", - ); - return "off"; -} + return "off"; + }); -async function signDarwinBinaries(mode: SignMode) { - if (mode === "off") { - return; - } + const signDarwinBinaries = Effect.fnUntraced(function* (mode: SignMode) { + if (mode === "off") { + return; + } - const darwinTargets = TARGETS.filter((target) => target.bunTarget.startsWith("bun-darwin")); - const binaries = darwinBinaries(); + const darwinTargets = TARGETS.filter((target) => target.bunTarget.startsWith("bun-darwin")); + const binaries = darwinBinaries(); - for (const target of darwinTargets) { - const binDir = path.join(root, "packages", target.pkg, "bin"); + for (const target of darwinTargets) { + const binDir = path.join(root, "packages", target.pkg, "bin"); - for (const binary of binaries) { - const binPath = path.join(binDir, binary); - const identifier = MACOS_IDENTIFIERS[binary]; - - console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); - // No key material, so rcodesign produces an ad-hoc signature, equivalent to - // `codesign --sign -`, replacing Bun/Go's linker-signed one. - await $`rcodesign sign --binary-identifier ${identifier} ${binPath}`; - - // Matches the identifier's whole value, so the SFE's `com.supabase.cli` can't satisfy - // the sidecar's `com.supabase.cli-go`, and confirms the signature is no longer linker-signed. - const info = await $`rcodesign print-signature-info ${binPath}`.text(); - const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; - if (signedIdentifier !== identifier) { - throw new Error( - `Signing ${binPath} failed: expected identifier ${identifier}, got ${signedIdentifier ?? "none"}`, - ); - } - if (info.includes("LINKER_SIGNED")) { - throw new Error(`Signing ${binPath} failed: signature is still linker-signed`); + for (const binary of binaries) { + const binPath = path.join(binDir, binary); + const identifier = MACOS_IDENTIFIERS[binary]; + + yield* Console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); + // No key material, so rcodesign produces an ad-hoc signature, equivalent to + // `codesign --sign -`, replacing Bun's linker-signed one. + yield* step(() => $`rcodesign sign --binary-identifier ${identifier} ${binPath}`); + + const info = yield* step(() => $`rcodesign print-signature-info ${binPath}`.text()); + const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; + if (signedIdentifier !== identifier) { + throw new Error( + `Signing ${binPath} failed: expected identifier ${identifier}, got ${signedIdentifier ?? "none"}`, + ); + } + if (info.includes("LINKER_SIGNED")) { + throw new Error(`Signing ${binPath} failed: signature is still linker-signed`); + } } } - } -} + }); -async function archiveTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - const archivePath = path.join(distDir, target.archive); - - console.log(`[${target.pkg}] Creating archive ${target.archive}...`); - - if (target.archive.endsWith(".zip")) { - const files = [ - path.join(binDir, `supabase${target.ext}`), - path.join(binDir, `supabase-go${target.ext}`), - ]; - await $`zip -j ${archivePath} ${files}`; - - // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so - // publish one alongside the .zip. See #5257. - const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); - const tarArchivePath = path.join(distDir, tarArchive); - const tarFiles = [`supabase${target.ext}`, `supabase-go${target.ext}`]; - console.log(`[${target.pkg}] Creating archive ${tarArchive}...`); - await $`tar -czf ${tarArchivePath} -C ${binDir} ${tarFiles}`; - } else { - const files = [`supabase${target.ext}`, `supabase-go${target.ext}`]; - await $`tar -czf ${archivePath} -C ${binDir} ${files}`; - } -} + const archiveTarget = Effect.fnUntraced(function* (target: (typeof TARGETS)[number]) { + const binDir = path.join(root, "packages", target.pkg, "bin"); + const archivePath = path.join(distDir, target.archive); -async function buildMuslBinaries() { - await Promise.all( - MUSL_TARGETS.map(async (target) => { - const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); - - const outfile = path.join(binDir, "supabase"); - console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`); - await runBunBuild({ - entrypoints: [entrypoint], - compile: { target: target.bunTarget, outfile }, - define: { - ...buildDefines, - SUPABASE_CLI_VERSION: JSON.stringify(version), - }, - }); - - // The Go binary is fully static (CGO_ENABLED=0), so the glibc build works on musl too; - // copy it into the musl package so GoProxy finds supabase-go there. - const glibcTarget = TARGETS.find( - (candidate) => "nfpmArch" in candidate && candidate.nfpmArch === target.nfpmArch, - ); - if (!glibcTarget) { - throw new Error(`No glibc Linux target found for musl arch ${target.nfpmArch}`); - } - const src = path.join(root, "packages", glibcTarget.pkg, "bin", "supabase-go"); - const dst = path.join(binDir, "supabase-go"); - console.log(`[${target.pkg}] Copying Go binary from ${glibcTarget.pkg}...`); - await copyFile(src, dst); + yield* Console.log(`[${target.pkg}] Creating archive ${target.archive}...`); - console.log(`[${target.pkg}] Done.`); - }), - ); -} + if (target.archive.endsWith(".zip")) { + const files = [path.join(binDir, `supabase${target.ext}`)]; + yield* step(() => $`zip -j ${archivePath} ${files}`); -async function buildLinuxPackages(version: string) { - const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); - const jobs: Promise[] = []; - - for (const target of linuxTargets) { - const glibcBinDir = path.join(root, "packages", target.pkg, "bin"); - const muslTarget = MUSL_TARGETS.find((candidate) => candidate.nfpmArch === target.nfpmArch)!; - const muslBinDir = path.join(root, "packages", muslTarget.pkg, "bin"); - - for (const fmt of LINUX_PKG_FORMATS) { - const outFile = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; - const outPath = path.join(distDir, outFile); - const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; - - // The Go binary is fully static, so apk (musl) still references supabase-go - // from the glibc dir where it was built. - const contents: Array<{ src: string; dst: string }> = [ - { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, - { src: path.join(glibcBinDir, "supabase-go"), dst: "/usr/bin/supabase-go" }, - ]; - - const nfpmConfig: Record = { - name: "supabase", - arch: target.nfpmArch, - platform: "linux", - version, - maintainer: "Supabase ", - description: "Supabase CLI", - homepage: "https://supabase.com", - license: "MIT", - contents, - }; - - if (fmt === "apk") { - nfpmConfig.depends = ["libstdc++", "libgcc"]; - } + // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so + // publish one alongside the .zip. See #5257. + const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); + const tarArchivePath = path.join(distDir, tarArchive); + const tarFiles = [`supabase${target.ext}`]; + yield* Console.log(`[${target.pkg}] Creating archive ${tarArchive}...`); + yield* step(() => $`tar -czf ${tarArchivePath} -C ${binDir} ${tarFiles}`); + } else { + const files = [`supabase${target.ext}`]; + yield* step(() => $`tar -czf ${archivePath} -C ${binDir} ${files}`); + } + }); - const configPath = path.join(distDir, `nfpm-${target.nfpmArch}-${fmt}.yaml`); - await writeFile(configPath, JSON.stringify(nfpmConfig)); + const buildMuslBinaries = Effect.fnUntraced(function* () { + yield* step(() => + Promise.all( + MUSL_TARGETS.map((target) => + runJob( + Effect.gen(function* () { + const binDir = path.join(root, "packages", target.pkg, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + const outfile = path.join(binDir, "supabase"); + yield* Console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`); + yield* runBunBuild({ + entrypoints: [entrypoint], + compile: { target: target.bunTarget, outfile }, + define: { + ...buildDefines, + SUPABASE_CLI_VERSION: yield* encodeJson(version), + }, + }); + + yield* Console.log(`[${target.pkg}] Done.`); + }), + ), + ), + ), + ); + }); - jobs.push( - (async () => { - console.log(`[${target.pkg}] Creating ${outFile}...`); - await $`nfpm package --config ${configPath} --packager ${fmt} --target ${outPath}`; - await rm(configPath); - })(), - ); + const buildLinuxPackages = Effect.fnUntraced(function* (version: string) { + const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); + const jobs: Promise[] = []; + + for (const target of linuxTargets) { + const glibcBinDir = path.join(root, "packages", target.pkg, "bin"); + const muslTarget = MUSL_TARGETS.find((candidate) => candidate.nfpmArch === target.nfpmArch)!; + const muslBinDir = path.join(root, "packages", muslTarget.pkg, "bin"); + + for (const fmt of LINUX_PKG_FORMATS) { + const outFile = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; + const outPath = path.join(distDir, outFile); + const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; + + const contents: Array<{ src: string; dst: string }> = [ + { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, + ]; + + const nfpmConfig: Record = { + name: "supabase", + arch: target.nfpmArch, + platform: "linux", + version, + maintainer: "Supabase ", + description: "Supabase CLI", + homepage: "https://supabase.com", + license: "MIT", + contents, + }; + + if (fmt === "apk") { + nfpmConfig.depends = ["libstdc++", "libgcc"]; + } + + const configPath = path.join(distDir, `nfpm-${target.nfpmArch}-${fmt}.yaml`); + yield* fs.writeFileString(configPath, yield* encodeJson(nfpmConfig)); + + jobs.push( + runJob( + Effect.gen(function* () { + yield* Console.log(`[${target.pkg}] Creating ${outFile}...`); + yield* step( + () => $`nfpm package --config ${configPath} --packager ${fmt} --target ${outPath}`, + ); + yield* fs.remove(configPath); + }), + ), + ); + } } - } - await Promise.all(jobs); -} + yield* step(() => Promise.all(jobs)); + }); -async function generateChecksums() { - const lines: string[] = []; + const generateChecksums = Effect.fnUntraced(function* () { + const lines: string[] = []; - for (const target of TARGETS) { - const archivePath = path.join(distDir, target.archive); - const data = await readFile(archivePath); - const hash = createHash("sha256").update(data).digest("hex"); - lines.push(`${hash} ${target.archive}`); + for (const target of TARGETS) { + const archivePath = path.join(distDir, target.archive); + const data = yield* fs.readFile(archivePath); + const hash = createHash("sha256").update(data).digest("hex"); + lines.push(`${hash} ${target.archive}`); - if (target.archive.endsWith(".zip")) { - const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); - const tarData = await readFile(path.join(distDir, tarArchive)); - const tarHash = createHash("sha256").update(tarData).digest("hex"); - lines.push(`${tarHash} ${tarArchive}`); + if (target.archive.endsWith(".zip")) { + const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); + const tarData = yield* fs.readFile(path.join(distDir, tarArchive)); + const tarHash = createHash("sha256").update(tarData).digest("hex"); + lines.push(`${tarHash} ${tarArchive}`); + } } - } - const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); - for (const target of linuxTargets) { - for (const fmt of LINUX_PKG_FORMATS) { - const filename = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; - const data = await readFile(path.join(distDir, filename)); - const hash = createHash("sha256").update(data).digest("hex"); - lines.push(`${hash} ${filename}`); + const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); + for (const target of linuxTargets) { + for (const fmt of LINUX_PKG_FORMATS) { + const filename = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; + const data = yield* fs.readFile(path.join(distDir, filename)); + const hash = createHash("sha256").update(data).digest("hex"); + lines.push(`${hash} ${filename}`); + } } - } - const checksumsPath = path.join(distDir, "checksums.txt"); - await writeFile(checksumsPath, `${lines.join("\n")}\n`); - console.log("Checksums written to dist/checksums.txt"); -} + const checksumsPath = path.join(distDir, "checksums.txt"); + yield* fs.writeFileString(checksumsPath, `${lines.join("\n")}\n`); + yield* Console.log("Checksums written to dist/checksums.txt"); + }); + + yield* Console.log(`Building the CLI for ${TARGETS.length} targets...\n`); -console.log(`Building the CLI for ${TARGETS.length} targets...\n`); + yield* step(() => Promise.all(TARGETS.map((target) => runJob(buildTarget(target))))); -await Promise.all(TARGETS.map(buildTarget)); + // Must run before archiveTarget / buildLinuxPackages / generateChecksums so every + // distribution channel ships the signed bytes. + const signMode = yield* resolveSignMode(); + yield* Console.log(`\nSigning macOS binaries (mode: ${signMode})...`); + yield* signDarwinBinaries(signMode); -console.log("\nCompiling Go CLI for all targets..."); -await Promise.all(TARGETS.map(buildGoTarget)); + yield* fs.makeDirectory(distDir, { recursive: true }); + yield* step(() => Promise.all(TARGETS.map((target) => runJob(archiveTarget(target))))); -// Must run before archiveTarget / buildLinuxPackages / generateChecksums so every -// distribution channel ships the signed bytes. -const signMode = resolveSignMode(); -console.log(`\nSigning macOS binaries (mode: ${signMode})...`); -await signDarwinBinaries(signMode); + yield* buildMuslBinaries(); + yield* buildLinuxPackages(version); + yield* generateChecksums(); -await mkdir(distDir, { recursive: true }); -await Promise.all(TARGETS.map(archiveTarget)); + yield* Console.log("\nAll targets built successfully."); +}); -await buildMuslBinaries(); -await buildLinuxPackages(version); -await generateChecksums(); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); +} -console.log("\nAll targets built successfully."); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index b18cf9a2e2..93797ca83c 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -20,8 +20,7 @@ export const compileOptions = { * Embeds the stack release of the sources being compiled, so the binary drives exactly the owners * started from the same stack sources, whether compiled or run from source. */ -export const stackReleaseDefine = async () => ({ - SUPABASE_STACK_BUILD_ID: JSON.stringify( - await Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))), - ), -}); +export const stackReleaseDefine = () => + Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))).then((digest) => ({ + SUPABASE_STACK_BUILD_ID: JSON.stringify(digest), + })); diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index 5bf3319f12..2b69f24c3c 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,9 +1,21 @@ // Detects images pinned in apps/cli/src/shared/services/Dockerfile that are missing from // any mirror registry and emits them as JSON, for the mirror-template-images workflow's backfill // matrix. -import { spawnSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; -import process from "node:process"; +import { constants } from "node:os"; +import { BunServices } from "@effect/platform-bun"; +import { + Config, + Console, + Data, + Deferred, + Effect, + FileSystem, + Predicate, + Schema, + Semaphore, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; /** @@ -43,26 +55,27 @@ export interface MirrorPartition { * any one registry lands in `missing`, since the backfill must re-push it everywhere; images * already mirrored everywhere land in `mirrored` and are skipped by a re-run. */ -export async function partitionUnmirroredImages( +export const partitionUnmirroredImages = ( images: Iterable, - isMirrored: (target: string) => Promise, + isMirrored: (target: string) => Effect.Effect, registries: ReadonlyArray = MIRROR_REGISTRIES, -): Promise { - const unique = [...new Set(images)]; - const results = await Promise.all( - unique.map(async (image) => { - const presence = await Promise.all( - mirrorImageTargets(image, registries).map((target) => isMirrored(target)), - ); - return { image, mirrored: presence.every(Boolean) }; - }), - ); +): Effect.Effect => + Effect.gen(function* () { + const unique = [...new Set(images)]; + const results = yield* Effect.forEach( + unique, + (image) => + Effect.forEach(mirrorImageTargets(image, registries), (target) => isMirrored(target), { + concurrency: "unbounded", + }).pipe(Effect.map((presence) => ({ image, mirrored: presence.every(Boolean) }))), + { concurrency: "unbounded" }, + ); - return { - mirrored: results.filter((result) => result.mirrored).map((result) => result.image), - missing: results.filter((result) => !result.mirrored).map((result) => result.image), - }; -} + return { + mirrored: results.filter((result) => result.mirrored).map((result) => result.image), + missing: results.filter((result) => !result.mirrored).map((result) => result.image), + }; + }); /** * Interprets a `docker buildx imagetools inspect` result. Only a registry "not found" means the @@ -91,66 +104,222 @@ export interface InspectResult { /** Backoff before each retry of a rate-limited (HTTP 429 or OCI `toomanyrequests`) registry probe. */ export const RATE_LIMIT_RETRY_DELAYS_MS: ReadonlyArray = [10_000, 30_000, 60_000]; +class MirrorInspectError extends Data.TaggedError("MirrorInspectError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + /** * Runs a registry probe, retrying with backoff while the registry rate-limits it (HTTP 429 or OCI * `toomanyrequests`); the anonymous public.ecr.aws quota is shared per runner IP and usually * recovers within a minute. */ -export async function inspectWithRateLimitRetry( +export const inspectWithRateLimitRetry = ( target: string, - inspect: () => InspectResult, - sleep: (ms: number) => Promise, + inspect: Effect.Effect, + sleep: (ms: number) => Effect.Effect, delaysMs: ReadonlyArray = RATE_LIMIT_RETRY_DELAYS_MS, -): Promise { - for (let attempt = 0; ; attempt++) { - const { exit, stderr } = inspect(); - const delay = delaysMs[attempt]; - if ( - exit !== 0 && - /429 Too Many Requests|toomanyrequests/i.test(stderr) && - delay !== undefined - ) { - console.error(`rate limited inspecting ${target}, retrying in ${delay}ms`); - await sleep(delay); - continue; +): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 0; ; attempt++) { + const { exit, stderr } = yield* inspect; + const delay = delaysMs[attempt]; + if ( + exit !== 0 && + /429 Too Many Requests|toomanyrequests/i.test(stderr) && + delay !== undefined + ) { + yield* Console.error(`rate limited inspecting ${target}, retrying in ${delay}ms`); + yield* sleep(delay); + continue; + } + return yield* Effect.try({ + try: () => inspectResultIsMirrored(target, exit, stderr), + catch: (cause) => + new MirrorInspectError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); } - return inspectResultIsMirrored(target, exit, stderr); - } -} + }); -function imageExistsOnMirror(target: string): Promise { - return inspectWithRateLimitRetry( - target, - () => { - const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { - encoding: "utf8", - stdio: ["ignore", "ignore", "pipe"], - }); - if (result.error) { - throw result.error; - } - return { exit: result.status ?? result.signal, stderr: result.stderr }; +const platformMessage = (error: { readonly message: string; readonly cause?: unknown }) => + error.cause instanceof Error ? error.cause.message : error.message; + +const isSignal = (name: string): name is NodeJS.Signals => Object.hasOwn(constants.signals, name); + +const receivedSignal = (error: { readonly message: string; readonly cause?: unknown }) => { + const name = /^Process interrupted due to receipt of signal: '(.+)'$/.exec( + platformMessage(error), + )?.[1]; + return name !== undefined && isSignal(name) ? name : undefined; +}; + +const MAX_STDERR_BYTES = 1024 * 1024; + +function stderrOverflowError(args: ReadonlyArray): Error { + return Object.assign( + new Error("spawnSync docker ENOBUFS (stdout or stderr buffer reached maxBuffer size limit)"), + { + path: "docker", + syscall: "spawnSync docker", + errno: -constants.errno.ENOBUFS, + code: "ENOBUFS", + spawnargs: [...args], }, - (ms) => new Promise((resolve) => setTimeout(resolve, ms)), ); } -if (import.meta.main) { +/* Decodes the whole stderr at once and keeps a leading byte order mark. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + +const inspectMirrorTarget = (target: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = ["buildx", "imagetools", "inspect", target]; + const handle = yield* spawner + .spawn( + ChildProcess.make("docker", args, { + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }), + ) + .pipe( + /* Spawn errors keep the `spawnSync docker` syscall label. */ + Effect.tapError((error) => + Effect.sync(() => { + if ( + Predicate.hasProperty(error.cause, "syscall") && + error.cause.syscall === "spawn docker" + ) { + error.cause.syscall = "spawnSync docker"; + } + }), + ), + Effect.catchDefect((defect) => + Effect.fail( + new MirrorInspectError({ + message: defect instanceof Error ? defect.message : String(defect), + cause: defect, + }), + ), + ), + ); + const chunks: Array = []; + let stderrBytes = 0; + const readStderr = Stream.runForEach(handle.stderr, (chunk) => + Effect.suspend(() => { + stderrBytes += chunk.byteLength; + if (stderrBytes > MAX_STDERR_BYTES) { + const cause = stderrOverflowError(args); + return Effect.fail(new MirrorInspectError({ message: cause.message, cause })); + } + chunks.push(chunk); + return Effect.void; + }), + ).pipe( + Effect.map(() => { + const bytes = new Uint8Array(stderrBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return utf8.decode(bytes); + }), + ); + const [stderr, exit] = yield* Effect.all( + [ + readStderr, + handle.exitCode.pipe( + Effect.map((code): number | NodeJS.Signals => Number(code)), + Effect.catch((error) => { + const signal = receivedSignal(error); + return signal === undefined ? Effect.fail(error) : Effect.succeed(signal); + }), + ), + ], + { concurrency: "unbounded" }, + ); + return { exit, stderr } satisfies InspectResult; + }).pipe( + Effect.scoped, + Effect.catchTag("PlatformError", (error) => + Effect.fail(new MirrorInspectError({ message: platformMessage(error), cause: error.cause })), + ), + ); + +const encodeMissing = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); + +const main = Effect.gen(function* () { + /* Probes run one at a time, as the registries rate-limit bursts; a rate-limit backoff does not + hold the permit, so other probes continue meanwhile. */ + const probe = yield* Semaphore.make(1); const images = dockerfileServiceImages.map((spec) => spec.image); - const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); + + /* Every target gets its first probe before a probe failure aborts detection, and the earliest + failure is the one reported. */ + let firstProbesLeft = new Set(images).size * MIRROR_REGISTRIES.length; + const firstProbesDone = yield* Deferred.make(); + if (firstProbesLeft === 0) { + yield* Deferred.succeed(firstProbesDone, undefined); + } + const probeFailure = yield* Deferred.make(); + const imageExistsOnMirror = (target: string) => + Effect.suspend(() => { + let firstProbe = true; + const inspect = probe.withPermit(inspectMirrorTarget(target)).pipe( + Effect.ensuring( + Effect.suspend(() => { + if (!firstProbe) { + return Effect.void; + } + firstProbe = false; + firstProbesLeft -= 1; + return firstProbesLeft === 0 + ? Deferred.succeed(firstProbesDone, undefined) + : Effect.void; + }), + ), + ); + return inspectWithRateLimitRetry(target, inspect, (ms) => Effect.sleep(ms)).pipe( + Effect.catch((error) => + Deferred.fail(probeFailure, error).pipe(Effect.andThen(Effect.never)), + ), + ); + }); + + const { mirrored, missing } = yield* Effect.raceFirst( + partitionUnmirroredImages(images, imageExistsOnMirror), + Deferred.await(firstProbesDone).pipe(Effect.andThen(Deferred.await(probeFailure))), + ); for (const image of mirrored) { - console.error(`already mirrored: ${image}`); + yield* Console.error(`already mirrored: ${image}`); } for (const image of missing) { - console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); + yield* Console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); } - const json = JSON.stringify(missing); - console.log(json); + const json = yield* encodeMissing(missing); + yield* Console.log(json); // Expose the list to the workflow as a step output when running in CI. - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `missing=${json}\n`); + const githubOutput = yield* Config.String("GITHUB_OUTPUT").pipe(Config.withDefault("")); + if (githubOutput) { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(githubOutput, `missing=${json}\n`, { flag: "a" }); } +}); + +if (import.meta.main) { + await Effect.runPromise( + main.pipe( + Effect.mapError((error) => error.cause ?? error), + Effect.provide(BunServices.layer), + ), + ); } diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts index 07b699f80a..00a43b0f15 100644 --- a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -1,3 +1,5 @@ +import { it } from "@effect/vitest"; +import { Effect } from "effect"; import { describe, expect, test } from "vitest"; import { inspectResultIsMirrored, @@ -26,41 +28,46 @@ describe("detect unmirrored images", () => { ]); }); - test("an image is mirrored only when present on ALL registries", async () => { - const present = new Set([ - // kong is on both registries -> mirrored. - "public.ecr.aws/supabase/kong:2.8.1", - "ghcr.io/supabase/kong:2.8.1", - // postgrest is only on ghcr.io -> partial mirror, must be re-pushed. - "ghcr.io/supabase/postgrest:v14.14", - ]); - const queried: string[] = []; - const isMirrored = (target: string) => { - queried.push(target); - return Promise.resolve(present.has(target)); - }; + it.effect("an image is mirrored only when present on ALL registries", () => + Effect.gen(function* () { + const present = new Set([ + // kong is on both registries -> mirrored. + "public.ecr.aws/supabase/kong:2.8.1", + "ghcr.io/supabase/kong:2.8.1", + // postgrest is only on ghcr.io -> partial mirror, must be re-pushed. + "ghcr.io/supabase/postgrest:v14.14", + ]); + const queried: string[] = []; + const isMirrored = (target: string) => + Effect.sync(() => { + queried.push(target); + return present.has(target); + }); - const { mirrored, missing } = await partitionUnmirroredImages( - // Duplicate kong to prove de-duplication. - ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], - isMirrored, - ); + const { mirrored, missing } = yield* partitionUnmirroredImages( + // Duplicate kong to prove de-duplication. + ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], + isMirrored, + ); - expect(mirrored).toEqual(["library/kong:2.8.1"]); - expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - expect(queried).toHaveLength(4); - }); + expect(mirrored).toEqual(["library/kong:2.8.1"]); + expect(missing).toEqual(["postgrest/postgrest:v14.14"]); + expect(queried).toHaveLength(4); + }), + ); - test("is a no-op once everything is on every registry (idempotent re-run)", async () => { - const allMirrored = () => Promise.resolve(true); - const { mirrored, missing } = await partitionUnmirroredImages( - ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], - allMirrored, - ); + it.effect("is a no-op once everything is on every registry (idempotent re-run)", () => + Effect.gen(function* () { + const allMirrored = () => Effect.succeed(true); + const { mirrored, missing } = yield* partitionUnmirroredImages( + ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], + allMirrored, + ); - expect(missing).toEqual([]); - expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); - }); + expect(missing).toEqual([]); + expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + }), + ); test("only a registry not-found marks an image as unmirrored", () => { const target = "public.ecr.aws/supabase/realtime:v2.144.2"; @@ -79,27 +86,37 @@ describe("detect unmirrored images", () => { ); }); - test("a rate-limited probe is retried with backoff and only aborts once retries run out", async () => { - const target = "public.ecr.aws/supabase/postgres:17.11.0.006"; - const rateLimited = { - exit: 1, - stderr: "ERROR: httpReadSeeker: failed open: unexpected status: 429 Too Many Requests\n", - }; - const slept: number[] = []; - const sleep = (ms: number) => { - slept.push(ms); - return Promise.resolve(); - }; + it.effect( + "a rate-limited probe is retried with backoff and only aborts once retries run out", + () => + Effect.gen(function* () { + const target = "public.ecr.aws/supabase/postgres:17.11.0.006"; + const rateLimited = { + exit: 1, + stderr: "ERROR: httpReadSeeker: failed open: unexpected status: 429 Too Many Requests\n", + }; + const slept: number[] = []; + const sleep = (ms: number) => + Effect.sync(() => { + slept.push(ms); + }); - const quotaExceeded = { exit: 1, stderr: "ERROR: toomanyrequests: Data limit exceeded\n" }; - const responses = [rateLimited, quotaExceeded, { exit: 0, stderr: "" }]; - await expect( - inspectWithRateLimitRetry(target, () => responses.shift()!, sleep, [1, 2, 3]), - ).resolves.toBe(true); - expect(slept).toEqual([1, 2]); + const quotaExceeded = { exit: 1, stderr: "ERROR: toomanyrequests: Data limit exceeded\n" }; + const responses = [rateLimited, quotaExceeded, { exit: 0, stderr: "" }]; + expect( + yield* inspectWithRateLimitRetry( + target, + Effect.sync(() => responses.shift()!), + sleep, + [1, 2, 3], + ), + ).toBe(true); + expect(slept).toEqual([1, 2]); - await expect(inspectWithRateLimitRetry(target, () => rateLimited, sleep, [1])).rejects.toThrow( - "429 Too Many Requests", - ); - }); + const error = yield* Effect.flip( + inspectWithRateLimitRetry(target, Effect.succeed(rateLimited), sleep, [1]), + ); + expect(error.message).toContain("429 Too Many Requests"); + }), + ); }); diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 83530c0455..9f85e9c2b4 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -8,8 +8,7 @@ * version is a semantic-release placeholder and is never used here. */ import { BunServices } from "@effect/platform-bun"; -import { Effect } from "effect"; -import path from "node:path"; +import { Effect, Path } from "effect"; import process from "node:process"; import { readDocsContent } from "../src/docs/docs-spec.content.ts"; import { buildDocsSpec, stringifyDocsSpec } from "../src/docs/docs-spec.ts"; @@ -22,7 +21,8 @@ function resolveVersion(): string { } const content = await Effect.runPromise( - readDocsContent(path.resolve(import.meta.dir, "../docs")).pipe( + Path.Path.pipe( + Effect.flatMap((path) => readDocsContent(path.resolve(import.meta.dir, "../docs"))), Effect.provide(BunServices.layer), Effect.mapError((error) => error.cause ?? error), ), diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index e359d5ca00..be5456e7f0 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -1,118 +1,134 @@ -import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import process from "node:process"; import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; -import { Lock, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; -import { Schema } from "effect"; +import { HOSTED_LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { Console, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { rootCommand } from "../src/cli/root.ts"; import { collectCommands, getHelpDoc } from "../src/shared/cli/command-docs.ts"; import { formatHelpDocAsMarkdown } from "../src/shared/cli/markdown-formatter.ts"; +import { lockSchemaDocument, publishedSchemaPath } from "./published-schemas.ts"; const BINARY_NAME = "supabase"; -const defaultContentDir = path.resolve(import.meta.dir, "../../../apps/docs/content/docs/commands"); -const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); -const configPackageDistDir = path.resolve(import.meta.dir, "../../../packages/config/dist"); -const contentDir = process.argv[2] - ? path.resolve(process.cwd(), process.argv[2]) - : defaultContentDir; - -function generateCommandDocs() { - const leaves = collectCommands(rootCommand, [BINARY_NAME]).filter( - ({ command, commandPath }) => commandPath.length > 1 && command.subcommands.length === 0, +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const defaultContentDir = path.resolve( + import.meta.dir, + "../../../apps/docs/content/docs/commands", ); - - const pages: Array<{ slug: string; title: string; description: string }> = []; - - for (const { command, commandPath } of leaves) { - const helpDoc = getHelpDoc(command, commandPath); - const body = formatHelpDocAsMarkdown(helpDoc); - - const title = commandPath.slice(1).join(" "); - const description = - (command as any).shortDescription ?? helpDoc.description?.split("\n")[0] ?? ""; - - const slug = commandPath.slice(1).join("/"); - const frontmatter = [ + const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); + const configPackageDistDir = path.resolve(import.meta.dir, "../../../packages/config/dist"); + const contentDir = process.argv[2] + ? path.resolve(process.cwd(), process.argv[2]) + : defaultContentDir; + + const generateCommandDocs = Effect.fnUntraced(function* () { + const leaves = collectCommands(rootCommand, [BINARY_NAME]).filter( + ({ command, commandPath }) => commandPath.length > 1 && command.subcommands.length === 0, + ); + + const pages: Array<{ slug: string; title: string; description: string }> = []; + + for (const { command, commandPath } of leaves) { + const helpDoc = getHelpDoc(command, commandPath); + const body = formatHelpDocAsMarkdown(helpDoc); + + const title = commandPath.slice(1).join(" "); + const description = + (command as any).shortDescription ?? helpDoc.description?.split("\n")[0] ?? ""; + + const slug = commandPath.slice(1).join("/"); + const frontmatter = [ + "---", + `title: "${BINARY_NAME} ${title}"`, + `description: "${description.replace(/"/g, '\\"')}"`, + "---", + ].join("\n"); + + const mdxContent = `${frontmatter}\n\n${body}`; + + const filePath = path.join(contentDir, `${slug}.mdx`); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, mdxContent); + pages.push({ slug, title, description }); + + yield* Console.log(`Generated: commands/${slug}.mdx`); + } + + const indexFrontmatter = [ "---", - `title: "${BINARY_NAME} ${title}"`, - `description: "${description.replace(/"/g, '\\"')}"`, + "title: Command reference", + "description: Complete reference for all Supabase CLI commands", "---", ].join("\n"); - const mdxContent = `${frontmatter}\n\n${body}`; - - const filePath = path.join(contentDir, `${slug}.mdx`); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, mdxContent); - pages.push({ slug, title, description }); - - console.log(`Generated: commands/${slug}.mdx`); - } - - const indexFrontmatter = [ - "---", - "title: Command reference", - "description: Complete reference for all Supabase CLI commands", - "---", - ].join("\n"); - - const rows = pages.map( - (page) => - `| [\`${BINARY_NAME} ${page.title}\`](/docs/commands/${page.slug}) | ${page.description} |`, - ); - const table = `| Command | Description |\n| --- | --- |\n${rows.join("\n")}`; - const indexContent = `${indexFrontmatter}\n\n${table}\n`; - - writeFileSync(path.join(contentDir, "index.mdx"), indexContent); - console.log("Generated: commands/index.mdx"); - - const metaContent = { - title: "Commands", - pages: ["index", ...pages.map((page) => page.slug.split("/").pop())], - }; - writeFileSync(path.join(contentDir, "meta.json"), JSON.stringify(metaContent, null, 2)); - - console.log(`\nGenerated ${pages.length} command page(s)`); -} - -/** - * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site - * public path rather than re-rendering it here, since re-rendering would bypass - * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires - * `@supabase/config#build` to have already run (wired via `turbo.json`). - */ -function copyConfigSchemaAsset(schemaUrl: string, distFileName: string) { - const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); - const sourcePath = path.join(configPackageDistDir, distFileName); - - mkdirSync(path.dirname(destPath), { recursive: true }); - copyFileSync(sourcePath, destPath); - - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`); -} - -/** Writes the `supabase.lock` v1 JSON Schema, generated from the stack's lock schema, to its docs-site public path. */ -function writeLockSchemaAsset() { - const { schema, definitions } = Schema.toJsonSchemaDocument(Lock); - const document = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: LOCK_SCHEMA_URL, - title: "supabase.lock", - description: "The service versions that supabase start pins in supabase/supabase.lock.", - ...schema, - $defs: definitions, - }; - const schemaPathname = new URL(LOCK_SCHEMA_URL).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); - - mkdirSync(path.dirname(destPath), { recursive: true }); - writeFileSync(destPath, `${JSON.stringify(document, null, 2)}\n`); - - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`); + const rows = pages.map( + (page) => + `| [\`${BINARY_NAME} ${page.title}\`](/docs/commands/${page.slug}) | ${page.description} |`, + ); + const table = `| Command | Description |\n| --- | --- |\n${rows.join("\n")}`; + const indexContent = `${indexFrontmatter}\n\n${table}\n`; + + yield* fs.writeFileString(path.join(contentDir, "index.mdx"), indexContent); + yield* Console.log("Generated: commands/index.mdx"); + + const metaContent = { + title: "Commands", + pages: ["index", ...pages.map((page) => page.slug.split("/").pop())], + }; + yield* fs.writeFileString(path.join(contentDir, "meta.json"), yield* encodeJson(metaContent)); + + yield* Console.log(`\nGenerated ${pages.length} command page(s)`); + }); + + /** + * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site + * public path rather than re-rendering it here, since re-rendering would bypass + * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires + * `@supabase/config#build` to have already run (wired via `turbo.json`). + */ + const copyConfigSchemaAsset = Effect.fnUntraced(function* ( + schemaUrl: string, + distFileName: string, + ) { + const destPath = path.join(defaultDocsPublicDir, publishedSchemaPath(schemaUrl)); + const sourcePath = path.join(configPackageDistDir, distFileName); + + yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); + yield* fs.copyFile(sourcePath, destPath); + + yield* Console.log( + `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`, + ); + }); + + /** Writes the `supabase.lock` v1 JSON Schema, generated from the stack's lock schema, to its docs-site public path. */ + const writeLockSchemaAsset = Effect.fnUntraced(function* () { + const destPath = path.join(defaultDocsPublicDir, publishedSchemaPath(HOSTED_LOCK_SCHEMA_URL)); + + yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); + yield* fs.writeFileString(destPath, `${yield* encodeJson(lockSchemaDocument())}\n`); + + yield* Console.log( + `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`, + ); + }); + + yield* generateCommandDocs(); + yield* writeLockSchemaAsset(); + yield* copyConfigSchemaAsset(CLI_CONFIG_SCHEMA_URL, "schema.json"); + yield* copyConfigSchemaAsset(PROJECT_CONFIG_SCHEMA_URL, "project-schema.json"); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } -generateCommandDocs(); -writeLockSchemaAsset(); -copyConfigSchemaAsset(CLI_CONFIG_SCHEMA_URL, "schema.json"); -copyConfigSchemaAsset(PROJECT_CONFIG_SCHEMA_URL, "project-schema.json"); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/macos-signing.ts b/apps/cli/scripts/macos-signing.ts index 13b3037a63..b5d2656ae8 100644 --- a/apps/cli/scripts/macos-signing.ts +++ b/apps/cli/scripts/macos-signing.ts @@ -1,14 +1,12 @@ /** * macOS code-signing identifiers, shared by the signer (`build.ts`) and its verifier so both - * agree. `bun build --compile` and the Go linker emit an ad-hoc signature that macOS 26+ AMFI - * SIGKILLs at launch (GitHub #5556); re-signing with a full ad-hoc signature fixes it without - * Apple credentials. + * agree. `bun build --compile` emits an ad-hoc signature that macOS 26+ AMFI SIGKILLs at launch + * (GitHub #5556); re-signing with a full ad-hoc signature fixes it without Apple credentials. */ -export type MacBinaryName = "supabase" | "supabase-go"; +export type MacBinaryName = "supabase"; export const MACOS_IDENTIFIERS: Record = { supabase: "com.supabase.cli", - "supabase-go": "com.supabase.cli-go", }; /** @@ -16,10 +14,10 @@ export const MACOS_IDENTIFIERS: Record = { * macOS binary, so callers verifying an arbitrary path fail closed. */ export function macIdentifierFor(binary: string): string | undefined { - return binary === "supabase" || binary === "supabase-go" ? MACOS_IDENTIFIERS[binary] : undefined; + return binary === "supabase" ? MACOS_IDENTIFIERS[binary] : undefined; } -/** The macOS binaries shipped for the CLI: the Bun binary and its Go sidecar. */ +/** The macOS binaries shipped for the CLI. */ export function darwinBinaries(): MacBinaryName[] { - return ["supabase", "supabase-go"]; + return ["supabase"]; } diff --git a/apps/cli/scripts/npm-registry.ts b/apps/cli/scripts/npm-registry.ts new file mode 100644 index 0000000000..3759b8858c --- /dev/null +++ b/apps/cli/scripts/npm-registry.ts @@ -0,0 +1,132 @@ +import { Data, Effect, Ref, Schedule, Schema } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +const POLL_INTERVAL = "10 seconds"; +const POLL_BUDGET = "15 minutes"; +const PROBE_TIMEOUT = "30 seconds"; +const RETRYABLE_CLIENT_STATUSES = new Set([404, 408, 429]); + +const ABBREVIATED_PACKUMENT = "application/vnd.npm.install-v1+json"; + +const AbbreviatedPackument = Schema.Struct({ + versions: Schema.Record(Schema.String, Schema.Unknown), +}); + +export interface PackageVersion { + readonly name: string; + readonly version: string; +} + +export class NpmRegistryRejected extends Data.TaggedError("NpmRegistryRejected")<{ + readonly message: string; +}> {} + +export class NpmPackagesNotInstallable extends Data.TaggedError("NpmPackagesNotInstallable")<{ + readonly message: string; + readonly packages: ReadonlyArray; +}> {} + +type Probe = + | { readonly installable: true } + | { readonly installable: false; readonly reason: string }; + +interface Pending { + readonly pkg: PackageVersion; + readonly reason: string; +} + +const installable: Probe = { installable: true }; +const notYet = (reason: string): Probe => ({ installable: false, reason }); + +const probe = Effect.fnUntraced( + function* (registryUrl: string, pkg: PackageVersion) { + const request = HttpClientRequest.get(`${registryUrl}/${pkg.name.replaceAll("/", "%2F")}`).pipe( + HttpClientRequest.accept(ABBREVIATED_PACKUMENT), + ); + const res = yield* HttpClient.execute(request); + + if (res.status >= 400 && res.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(res.status)) { + return yield* new NpmRegistryRejected({ + message: `npm registry probe for ${pkg.name}@${pkg.version} returned HTTP ${res.status}`, + }); + } + if (res.status !== 200) return notYet(`HTTP ${res.status}`); + + const packument = yield* HttpClientResponse.schemaBodyJson(AbbreviatedPackument)(res); + return Object.hasOwn(packument.versions, pkg.version) + ? installable + : notYet(`${pkg.version} not listed yet`); + }, + Effect.catchTags({ + HttpClientError: (error) => Effect.succeed(notYet(error.message)), + SchemaError: () => Effect.succeed(notYet("unreadable packument")), + }), + Effect.timeoutOrElse({ + duration: PROBE_TIMEOUT, + orElse: () => Effect.succeed(notYet(`no response within ${PROBE_TIMEOUT}`)), + }), +); + +/** Polls the registry until every package version is listed in its install packument. */ +export const waitUntilInstallable = Effect.fn("NpmRegistry.waitUntilInstallable")(function* (opts: { + readonly registryUrl: string; + readonly packages: ReadonlyArray; + readonly onInstallable: (pkg: PackageVersion) => Effect.Effect; +}) { + const { registryUrl, packages, onInstallable } = opts; + const pending = yield* Ref.make>( + packages.map((pkg) => ({ pkg, reason: "not probed" })), + ); + const attempts = yield* Ref.make(0); + + const probeRound = Effect.gen(function* () { + yield* Ref.update(attempts, (count) => count + 1); + const results = yield* Effect.forEach( + yield* Ref.get(pending), + ({ pkg }) => probe(registryUrl, pkg).pipe(Effect.map((result) => ({ pkg, result }))), + { concurrency: "unbounded" }, + ); + yield* Ref.set( + pending, + results.flatMap(({ pkg, result }) => + result.installable ? [] : [{ pkg, reason: result.reason }], + ), + ); + yield* Effect.forEach( + results.filter(({ result }) => result.installable), + ({ pkg }) => onInstallable(pkg), + { discard: true }, + ); + }).pipe(Effect.withTracerEnabled(false)); + + yield* probeRound.pipe( + Effect.repeat({ + schedule: Schedule.spaced(POLL_INTERVAL).pipe(Schedule.upTo({ duration: POLL_BUDGET })), + until: () => Ref.get(pending).pipe(Effect.map((remaining) => remaining.length === 0)), + }), + Effect.ensuring( + Effect.all([Ref.get(attempts), Ref.get(pending)]).pipe( + Effect.flatMap(([count, remaining]) => + Effect.annotateCurrentSpan({ + "retry.attempt_count": count, + "npm.package_count": packages.length, + "npm.pending_count": remaining.length, + }), + ), + ), + ), + ); + + const remaining = yield* Ref.get(pending); + if (remaining.length > 0) { + const labels = remaining + .map(({ pkg, reason }) => `${pkg.name}@${pkg.version} (last probe: ${reason})`) + .join(", "); + return yield* new NpmPackagesNotInstallable({ + message: + `Timed out after ${POLL_BUDGET} waiting for npm to make these packages installable: ${labels}. ` + + `Re-running the publish is safe; already-published packages are skipped.`, + packages: remaining.map(({ pkg }) => pkg), + }); + } +}); diff --git a/apps/cli/scripts/npm-registry.unit.test.ts b/apps/cli/scripts/npm-registry.unit.test.ts new file mode 100644 index 0000000000..f2639a2822 --- /dev/null +++ b/apps/cli/scripts/npm-registry.unit.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Fiber, Layer } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { TestClock } from "effect/testing"; +import { + NpmPackagesNotInstallable, + NpmRegistryRejected, + waitUntilInstallable, + type PackageVersion, +} from "./npm-registry.ts"; + +const REGISTRY_URL = "https://registry.test"; +const VERSION = "3.0.0"; +const DARWIN: PackageVersion = { name: "@supabase/cli-darwin-arm64", version: VERSION }; +const WINDOWS: PackageVersion = { name: "@supabase/cli-windows-x64", version: VERSION }; + +const notFound = () => new Response("{}", { status: 404 }); +const forbidden = () => new Response("{}", { status: 403 }); +const packument = (versions: ReadonlyArray) => + new Response( + JSON.stringify({ versions: Object.fromEntries(versions.map((v) => [v, { version: v }])) }), + { status: 200 }, + ); + +/** `respond` receives the request URL and how many times that URL was requested before. */ +const stubRegistry = (respond: (url: string, attempt: number) => Response) => { + const requests: Array<{ url: string; accept: string | undefined }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const attempt = requests.filter((r) => r.url === request.url).length; + requests.push({ url: request.url, accept: request.headers.accept }); + return HttpClientResponse.fromWeb(request, respond(request.url, attempt)); + }), + ), + ); + return { layer, requests }; +}; + +describe("waitUntilInstallable", () => { + it.effect("resolves once the install packument lists the version", () => { + const registry = stubRegistry((_url, attempt) => + attempt === 0 + ? notFound() + : attempt === 1 + ? packument(["2.9.0"]) + : packument(["2.9.0", VERSION]), + ); + return Effect.gen(function* () { + const installable: Array = []; + const fiber = yield* waitUntilInstallable({ + registryUrl: REGISTRY_URL, + packages: [DARWIN], + onInstallable: (pkg) => Effect.sync(() => void installable.push(pkg.name)), + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("10 seconds"); + expect(fiber.pollUnsafe()).toBeUndefined(); + expect(installable).toEqual([]); + + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(fiber); + + expect(installable).toEqual([DARWIN.name]); + expect(registry.requests).toEqual( + Array.from({ length: 3 }, () => ({ + url: "https://registry.test/@supabase%2Fcli-darwin-arm64", + accept: "application/vnd.npm.install-v1+json", + })), + ); + }).pipe(Effect.provide(registry.layer)); + }); + + it.effect("abandons a hung probe and keeps polling", () => { + let attempts = 0; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + attempts++ === 0 + ? Effect.never + : Effect.succeed(HttpClientResponse.fromWeb(request, packument([VERSION]))), + ), + ); + return Effect.gen(function* () { + const fiber = yield* waitUntilInstallable({ + registryUrl: REGISTRY_URL, + packages: [DARWIN], + onInstallable: () => Effect.void, + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 minute"); + yield* Fiber.join(fiber); + + expect(attempts).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails naming only the packages that never became installable", () => { + const registry = stubRegistry((url) => + packument(url.includes("darwin") ? [VERSION] : ["2.9.0"]), + ); + return Effect.gen(function* () { + const installable: Array = []; + const fiber = yield* waitUntilInstallable({ + registryUrl: REGISTRY_URL, + packages: [DARWIN, WINDOWS], + onInstallable: (pkg) => Effect.sync(() => void installable.push(pkg.name)), + }).pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("14 minutes"); + expect(fiber.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("2 minutes"); + const error = yield* Fiber.join(fiber); + + expect(error).toBeInstanceOf(NpmPackagesNotInstallable); + expect(error.message).toContain( + `${WINDOWS.name}@${VERSION} (last probe: ${VERSION} not listed yet)`, + ); + expect(error.message).not.toContain(DARWIN.name); + expect(installable).toEqual([DARWIN.name]); + }).pipe(Effect.provide(registry.layer)); + }); + + it.effect("keeps polling through request timeouts and throttling", () => { + const registry = stubRegistry((_url, attempt) => + attempt === 0 + ? new Response("{}", { status: 408 }) + : attempt === 1 + ? new Response("{}", { status: 429 }) + : packument([VERSION]), + ); + return Effect.gen(function* () { + const fiber = yield* waitUntilInstallable({ + registryUrl: REGISTRY_URL, + packages: [DARWIN], + onInstallable: () => Effect.void, + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(fiber); + + expect(registry.requests).toHaveLength(3); + }).pipe(Effect.provide(registry.layer)); + }); + + it.effect("fails immediately on a client error other than 404, 408 or 429", () => { + const registry = stubRegistry(() => forbidden()); + return Effect.gen(function* () { + const fiber = yield* waitUntilInstallable({ + registryUrl: REGISTRY_URL, + packages: [DARWIN], + onInstallable: () => Effect.void, + }).pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 minute"); + expect(registry.requests).toHaveLength(1); + const error = yield* Fiber.join(fiber); + + expect(error).toBeInstanceOf(NpmRegistryRejected); + expect(error.message).toContain("HTTP 403"); + }).pipe(Effect.provide(registry.layer)); + }); +}); diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index c1ab14505e..225090a74a 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -7,14 +7,24 @@ // Usage: bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 [--dry-run] [--apply] // [--render-only] [--model ] import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path, Stream } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import aiModels from "../../../.github/ai-models.json"; +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class AgentQueryFailed extends Data.TaggedError("AgentQueryFailed")<{ readonly cause: unknown }> {} + +const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + const { values } = parseArgs({ options: { tag: { type: "string" }, @@ -26,129 +36,147 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.101.0)"); - process.exit(2); -} -const version = tag.replace(/^v/, ""); -const apply = values.apply === true && values["dry-run"] !== true; - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); -const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); -const notesDir = path.join(repoRoot, "release-notes"); -const notesPath = path.join(notesDir, `v${version}.md`); - -console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); -const rawNotes = (await $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text()).trim(); -if (!rawNotes) { - console.error(`backfill-release-notes produced no output for ${tag}`); - process.exit(1); -} +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; -const promptTemplate = await readFile(promptPath, "utf8"); -const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; -if (!promptTemplate.includes(placeholder)) { - console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); - process.exit(1); -} -const rendered = promptTemplate.replace(placeholder, rawNotes); + const tag = values.tag; + if (!tag) { + yield* Console.error("--tag is required (e.g. --tag v2.101.0)"); + return yield* Effect.sync(() => process.exit(2)); + } + const version = tag.replace(/^v/, ""); + const apply = values.apply === true && values["dry-run"] !== true; + + const repoRoot = (yield* shell(() => $`git rev-parse --show-toplevel`.text())).trim(); + const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); + const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); + const notesDir = path.join(repoRoot, "release-notes"); + const notesPath = path.join(notesDir, `v${version}.md`); + + yield* Console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); + const rawNotes = (yield* shell(() => + $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text(), + )).trim(); + if (!rawNotes) { + yield* Console.error(`backfill-release-notes produced no output for ${tag}`); + return yield* Effect.sync(() => process.exit(1)); + } -if (values["render-only"]) { - process.stdout.write(rendered); - process.exit(0); -} + const promptTemplate = utf8.decode(yield* fs.readFile(promptPath)); + const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; + if (!promptTemplate.includes(placeholder)) { + yield* Console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); + return yield* Effect.sync(() => process.exit(1)); + } + const rendered = promptTemplate.replace(placeholder, rawNotes); -console.error(`==> Running Claude Agent SDK (model=${values.model})`); -const options: Options = { - model: values.model, - // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for - // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the - // script owns the final file output. - allowedTools: ["WebFetch", "WebSearch", "Bash"], - // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. - settingSources: [], - cwd: repoRoot, - effort: "low", -}; - -let finalText = ""; -let cost = 0; -const stream = query({ prompt: rendered, options }); -for await (const msg of stream) { - if (msg.type === "result") { - if (msg.subtype === "success") { - finalText = msg.result; - cost = msg.total_cost_usd; - } else { - console.error(`Agent failed: ${msg.subtype}`); - if (msg.errors?.length) console.error(msg.errors.join("\n")); - process.exit(1); - } + if (values["render-only"]) { + return yield* Effect.sync(() => { + process.stdout.write(rendered); + process.exit(0); + }); } -} -if (!finalText.trim()) { - console.error("Agent returned no result text"); - process.exit(1); -} + yield* Console.error(`==> Running Claude Agent SDK (model=${values.model})`); + const options: Options = { + model: values.model, + // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the + // script owns the final file output. + allowedTools: ["WebFetch", "WebSearch", "Bash"], + // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. + settingSources: [], + cwd: repoRoot, + effort: "low", + }; + + let finalText = ""; + let cost = 0; + yield* Stream.fromAsyncIterable( + query({ prompt: rendered, options }), + (cause) => new AgentQueryFailed({ cause }), + ).pipe( + Stream.runForEach((msg) => + Effect.gen(function* () { + if (msg.type === "result") { + if (msg.subtype === "success") { + finalText = msg.result; + cost = msg.total_cost_usd; + } else { + yield* Console.error(`Agent failed: ${msg.subtype}`); + if (msg.errors?.length) yield* Console.error(msg.errors.join("\n")); + return yield* Effect.sync(() => process.exit(1)); + } + } + }), + ), + ); + + if (!finalText.trim()) { + yield* Console.error("Agent returned no result text"); + return yield* Effect.sync(() => process.exit(1)); + } -const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; -console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); + const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; + yield* Console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); -if (!apply) { - process.stdout.write(normalized); - process.exit(0); -} + if (!apply) { + return yield* Effect.sync(() => { + process.stdout.write(normalized); + process.exit(0); + }); + } -await mkdir(notesDir, { recursive: true }); -if (existsSync(notesPath)) { - console.error( - `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + - `Delete it or rerun with --dry-run to preview.`, - ); - process.exit(1); -} -await writeFile(notesPath, normalized); -console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); - -const branch = `release-notes/v${version}`; -// Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged -// doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. -// Targets develop rather than main so an accidental merge can't rewrite main's history. -await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); -await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); -await $`git add ${notesPath}`.cwd(repoRoot); -const commitMessage = `docs(release): propose user-facing notes for ${tag}`; -await $`git commit -m ${commitMessage}`.cwd(repoRoot); - -console.error(`==> Pushing ${branch}`); -let pushed = false; -for (let attempt = 0; attempt < 4; attempt++) { - const result = await $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(); - if (result.exitCode === 0) { - pushed = true; - break; + yield* fs.makeDirectory(notesDir, { recursive: true }); + if (yield* fs.exists(notesPath).pipe(Effect.orElseSucceed(() => false))) { + yield* Console.error( + `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + + `Delete it or rerun with --dry-run to preview.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + yield* fs.writeFileString(notesPath, normalized); + yield* Console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); + + const branch = `release-notes/v${version}`; + // Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged + // doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. + // Targets develop rather than main so an accidental merge can't rewrite main's history. + yield* shell(() => $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow()); + yield* shell(() => $`git checkout -B ${branch} origin/develop`.cwd(repoRoot)); + yield* shell(() => $`git add ${notesPath}`.cwd(repoRoot)); + const commitMessage = `docs(release): propose user-facing notes for ${tag}`; + yield* shell(() => $`git commit -m ${commitMessage}`.cwd(repoRoot)); + + yield* Console.error(`==> Pushing ${branch}`); + let pushed = false; + for (let attempt = 0; attempt < 4; attempt++) { + const result = yield* shell(() => $`git push -u origin ${branch}`.cwd(repoRoot).nothrow()); + if (result.exitCode === 0) { + pushed = true; + break; + } + const wait = 2 ** (attempt + 1) * 1000; + yield* Console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); + yield* Effect.sleep(wait); + } + if (!pushed) { + yield* Console.error("git push failed after 4 attempts"); + return yield* Effect.sync(() => process.exit(1)); } - const wait = 2 ** (attempt + 1) * 1000; - console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); - await new Promise((r) => setTimeout(r, wait)); -} -if (!pushed) { - console.error("git push failed after 4 attempts"); - process.exit(1); -} -// Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the -// approval workflow never actually merges. -const labelName = "do not merge"; -await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` - .cwd(repoRoot) - .nothrow(); + // Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the + // approval workflow never actually merges. + const labelName = "do not merge"; + yield* shell(() => + $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` + .cwd(repoRoot) + .nothrow(), + ); -const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; -const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. + const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; + const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. ## How to update the notes @@ -175,7 +203,21 @@ Close the PR without approving. The auto-generated semantic-release body for \`$ After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. `; -await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( - repoRoot, + yield* shell(() => + $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( + repoRoot, + ), + ); + yield* Console.error(`==> PR opened for ${branch}`); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); +} + +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(thrownValue(error))), + ), ); -console.error(`==> PR opened for ${branch}`); diff --git a/apps/cli/scripts/publish-docs-spec.e2e.test.ts b/apps/cli/scripts/publish-docs-spec.e2e.test.ts index 819c6b09d4..f13b0a7340 100644 --- a/apps/cli/scripts/publish-docs-spec.e2e.test.ts +++ b/apps/cli/scripts/publish-docs-spec.e2e.test.ts @@ -1,8 +1,8 @@ -import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { stringify } from "yaml"; -const cliRoot = path.resolve(import.meta.dirname, ".."); +const cliRoot = fileURLToPath(new URL("..", import.meta.url)); function runScript( args: ReadonlyArray, diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index 310f2be9ce..b937b64c9e 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -7,13 +7,21 @@ * No-op (exit 0) when the spec is already published and no PR is missing; a branch already ahead * of base still gets its PR ensured, so a failed prior run is repaired by the next release. */ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { parse } from "yaml"; +import { json, publishPullRequest, shell } from "./publish-pull-request.ts"; + +class StdinReadFailed extends Data.TaggedError("StdinReadFailed")<{ readonly cause: unknown }> {} + +class SpecYamlInvalid extends Data.TaggedError("SpecYamlInvalid")<{ readonly cause: unknown }> {} + +function jsonText(value: unknown): string { + return `${JSON.stringify(value)}`; +} const { values } = parseArgs({ options: { @@ -26,130 +34,109 @@ const { values } = parseArgs({ }, }); -const versionArgument = values.version; -if (!versionArgument) { - console.error( - "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", - ); - process.exit(1); -} -const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; - -const repo = values.repo!; -const specPath = values["spec-path"]!; -const branch = values.branch!; -const base = values.base!; -const dryRun = values["dry-run"]!; - -const spec = await Bun.stdin.text(); -if (spec.trim().length === 0) { - console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); - process.exit(1); -} +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; -let parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown }; -try { - parsed = parse(spec); -} catch (error) { - console.error(`Refusing to publish: stdin is not valid YAML (${error}).`); - process.exit(1); -} -if (parsed?.clispec !== "001") { - console.error( - `Refusing to publish: expected clispec "001", got ${JSON.stringify(parsed?.clispec)}.`, - ); - process.exit(1); -} -if (parsed.info?.version !== version) { - console.error( - `Refusing to publish: the spec's version ${JSON.stringify(parsed.info?.version)} does not match --version ${version}.`, - ); - process.exit(1); -} -const commands = parsed.commands; -if (!Array.isArray(commands) || commands.length < 144) { - console.error( - `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, - ); - process.exit(1); -} - -if (dryRun) { - console.log(`Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`); - process.exit(0); -} + const versionArgument = values.version; + if (!versionArgument) { + yield* Console.error( + "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; -// `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec -// lands as one commit on top, so commits pushed onto an open PR survive later releases. -const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); -try { - await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; + const repo = values.repo!; + const specPath = values["spec-path"]!; + const branch = values.branch!; + const base = values.base!; + const dryRun = values["dry-run"]!; - const remoteBranch = await $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}` - .nothrow() - .quiet(); - if (remoteBranch.exitCode === 0) { - await $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`; - } else { - await $`git -C ${tmpDir} checkout --quiet -B ${branch}`; + const spec = yield* Effect.tryPromise({ + try: () => Bun.stdin.text(), + catch: (cause) => new StdinReadFailed({ cause }), + }); + if (spec.trim().length === 0) { + yield* Console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); + return yield* Effect.sync(() => process.exit(1)); } - const target = path.join(tmpDir, specPath); - await writeFile(target, spec); - const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); - await $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`; - - const message = "chore: update cli reference doc"; - await $`git -C ${tmpDir} add ${specPath}`; - const unchanged = await $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}` - .nothrow() - .quiet(); - if (unchanged.exitCode === 0) { - console.log( - remoteBranch.exitCode === 0 - ? `${specPath} is already up to date on ${branch}` - : `${specPath} is already up to date in ${repo}`, + const parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown } = + yield* Effect.try({ + try: () => parse(spec), + catch: (cause) => new SpecYamlInvalid({ cause }), + }).pipe( + Effect.catch((error) => + Console.error(`Refusing to publish: stdin is not valid YAML (${error.cause}).`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), ); - } else { - await $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`; - await $`git -C ${tmpDir} push --quiet origin ${branch}`; - console.log(`Pushed ${branch} to ${repo}`); + if (parsed?.clispec !== "001") { + yield* Console.error( + `Refusing to publish: expected clispec "001", got ${yield* json(() => jsonText(parsed?.clispec))}.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + if (parsed.info?.version !== version) { + yield* Console.error( + `Refusing to publish: the spec's version ${yield* json(() => jsonText(parsed.info?.version))} does not match --version ${version}.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + const commands = parsed.commands; + if (!Array.isArray(commands) || commands.length < 144) { + yield* Console.error( + `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, + ); + return yield* Effect.sync(() => process.exit(1)); } - const unpublished = await $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}` - .nothrow() - .quiet(); - if (unpublished.exitCode === 1) { - const existing = - await $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` - .cwd(tmpDir) - .text(); - const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); - const ownPulls = openPulls.filter( - (pull) => - pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + if (dryRun) { + yield* Console.log( + `Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`, ); - if (ownPulls.length > 0) { - console.log(`Reusing the open PR for ${branch}`); - } else { - const body = [ - "Updates the CLI reference from the supabase/cli release workflow.", - "", - "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", - "command tree or the content under `apps/cli/docs/` there rather than this", - "file.", - "", - "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", - "without one a command's page is silently dropped from the docs site.", - "Sections fixes can be committed directly onto this branch — later releases", - "add commits on top instead of rewriting it.", - ].join("\n"); - await $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( - tmpDir, - ); - console.log(`Opened a pull request against ${repo}`); - } + return yield* Effect.sync(() => process.exit(0)); } -} finally { - await rm(tmpDir, { recursive: true, force: true }); -} + + const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); + const message = "chore: update cli reference doc"; + yield* publishPullRequest({ + repo, + base, + branch, + stagedPath: specPath, + write: (checkoutDir) => + Effect.gen(function* () { + const target = path.join(checkoutDir, specPath); + yield* fs.writeFileString(target, spec); + yield* shell( + () => + $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`, + ); + }), + commitMessage: message, + pullRequestTitle: message, + pullRequestBody: [ + "Updates the CLI reference from the supabase/cli release workflow.", + "", + "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", + "command tree or the content under `apps/cli/docs/` there rather than this", + "file.", + "", + "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", + "without one a command's page is silently dropped from the docs site.", + "Sections fixes can be committed directly onto this branch — later releases", + "add commits on top instead of rewriting it.", + ].join("\n"), + autoMerge: false, + }); +}); + +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(error.cause ?? error)), + ), +); diff --git a/apps/cli/scripts/publish-pull-request.ts b/apps/cli/scripts/publish-pull-request.ts new file mode 100644 index 0000000000..12ebd9aa20 --- /dev/null +++ b/apps/cli/scripts/publish-pull-request.ts @@ -0,0 +1,133 @@ +/** + * Publishes files to another repository through a pull request: clones it, writes on a reused or + * new branch, pushes when the staged diff is non-empty, and ensures a PR exists while the branch + * differs from base. + */ +import { $ } from "bun"; +import { Console, Data, Effect, FileSystem } from "effect"; + +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class JsonFailed extends Data.TaggedError("JsonFailed")<{ readonly cause: unknown }> {} + +export const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +/* Plain `JSON` keeps the engine's own error for a cyclic value or malformed `gh` output, + which a `Schema` codec would replace with its issue text. */ +export const json = (run: () => A) => + Effect.try({ try: run, catch: (cause) => new JsonFailed({ cause }) }); + +function ownPullsOf(existing: string, repo: string) { + const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); + return openPulls.filter( + (pull) => pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + ); +} + +export interface PublishPullRequestOptions { + readonly repo: string; + readonly base: string; + readonly branch: string; + /** Repo-relative file or directory that `write` changes; it is staged and diffed against base. */ + readonly stagedPath: string; + readonly write: (checkoutDir: string) => Effect.Effect; + readonly commitMessage: string; + readonly pullRequestTitle: string; + readonly pullRequestBody: string; + /** Arms `gh pr merge --auto --squash` on the open pull request, new or reused. */ + readonly autoMerge: boolean; +} + +export const publishPullRequest = ({ + repo, + base, + branch, + stagedPath, + write, + commitMessage, + pullRequestTitle, + pullRequestBody, + autoMerge, +}: PublishPullRequestOptions) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + // `--depth 1` suffices: an existing branch is fetched at its tip and the new content lands as + // one commit on top, so commits pushed onto an open PR survive later releases. + const publish = (tmpDir: string) => + Effect.gen(function* () { + yield* shell( + () => + $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`, + ); + + const remoteBranch = yield* shell(() => + $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}`.nothrow().quiet(), + ); + if (remoteBranch.exitCode === 0) { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`); + } else { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch}`); + } + + yield* write(tmpDir); + + yield* shell(() => $`git -C ${tmpDir} add ${stagedPath}`); + const unchanged = yield* shell(() => + $`git -C ${tmpDir} diff --quiet --cached -- ${stagedPath}`.nothrow().quiet(), + ); + if (unchanged.exitCode === 0) { + yield* Console.log( + remoteBranch.exitCode === 0 + ? `${stagedPath} is already up to date on ${branch}` + : `${stagedPath} is already up to date in ${repo}`, + ); + } else { + yield* shell( + () => + $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${commitMessage}`, + ); + yield* shell(() => $`git -C ${tmpDir} push --quiet origin ${branch}`); + yield* Console.log(`Pushed ${branch} to ${repo}`); + } + + const unpublished = yield* shell(() => + $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${stagedPath}`.nothrow().quiet(), + ); + if (unpublished.exitCode === 1) { + const existing = yield* shell(() => + $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` + .cwd(tmpDir) + .text(), + ); + const ownPulls = yield* json(() => ownPullsOf(existing, repo)); + if (ownPulls.length > 0) { + yield* Console.log(`Reusing the open PR for ${branch}`); + } else { + yield* shell(() => + $`gh pr create --repo ${repo} --title ${pullRequestTitle} --body ${pullRequestBody} --base ${base} --head ${branch}`.cwd( + tmpDir, + ), + ); + yield* Console.log(`Opened a pull request against ${repo}`); + } + if (autoMerge) { + yield* shell(() => $`gh pr merge ${branch} --repo ${repo} --auto --squash`.cwd(tmpDir)); + yield* Console.log(`Armed auto-merge for ${branch}`); + } + } + }); + + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "supabase-publish-" }); + const outcome = yield* Effect.exit(restore(publish(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true, force: true }); + return yield* outcome; + }), + ); + }); diff --git a/apps/cli/scripts/publish-schemas.e2e.test.ts b/apps/cli/scripts/publish-schemas.e2e.test.ts new file mode 100644 index 0000000000..bdcc1287b1 --- /dev/null +++ b/apps/cli/scripts/publish-schemas.e2e.test.ts @@ -0,0 +1,144 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, JsonSchema, Layer, Path } from "effect"; +import { fileURLToPath } from "node:url"; + +const cliRoot = fileURLToPath(new URL("..", import.meta.url)); +const platformLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + +const runScript = (args: ReadonlyArray) => + Effect.gen(function* () { + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...env } = process.env; + const child = Bun.spawn(["bun", "scripts/publish-schemas.ts", ...args], { + cwd: cliRoot, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }); + +const schemaText = (name: string, version: number, overrides: Record = {}) => + JSON.stringify({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, + $id: `https://supabase.com/schemas/${name}/v${version}.json`, + type: "object", + ...overrides, + }); + +const schemasDirOf = (files: Record) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "publish-schemas-" }); + for (const [file, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(directory, file)), { recursive: true }); + yield* fs.writeFileString(path.join(directory, file), content); + } + return directory; + }); + +const refusals: ReadonlyArray<{ + description: string; + files: Record; + message: string; +}> = [ + { + description: "an $id that does not match the schema's path", + files: { "config/v1.json": schemaText("config", 1, { $id: "https://example.com/c.json" }) }, + message: `Refusing to publish: config/v1.json must declare "$id": "https://supabase.com/schemas/config/v1.json".`, + }, + { + description: "a $schema that is not draft-07", + files: { + "config/v1.json": schemaText("config", 1, { + $schema: "https://json-schema.org/draft/2020-12/schema", + }), + }, + message: `Refusing to publish: config/v1.json must declare "$schema": "${JsonSchema.META_SCHEMA_URI_DRAFT_07}".`, + }, + { + description: "a file that is not valid JSON", + files: { "config/v1.json": "{ not json" }, + message: "Refusing to publish: config/v1.json is not a valid JSON object.", + }, + { + description: "a file in a schema directory that is not named v.json", + files: { "config/v1.json": schemaText("config", 1), "config/notes.md": "# notes" }, + message: "Refusing to publish: config/notes.md does not match v.json.", + }, + { + description: "a schemas directory without schemas", + files: {}, + message: "Refusing to publish: no schemas found in ", + }, +]; + +describe("publish-schemas.ts entrypoint", () => { + it.effect("prints usage and fails without --version", () => + Effect.gen(function* () { + const { exitCode, stderr } = yield* runScript(["--dry-run"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect("summarizes the committed schemas in dry-run mode without publishing", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runScript(["--version", "v2.0.0", "--dry-run"]); + + expect(exitCode, stderr).toBe(0); + expect(stdout.trim()).toMatch( + /^Would publish .+ to supabase\/supabase:apps\/www\/public\/schemas on branch cli\/schemas$/, + ); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect("summarizes only the highest version of each schema", () => + Effect.gen(function* () { + const schemasDir = yield* schemasDirOf({ + "config/v1.json": "frozen, never validated", + "config/v2.json": schemaText("config", 2), + "lock/v1.json": schemaText("lock", 1), + }); + + const { exitCode, stdout, stderr } = yield* runScript([ + "--version", + "1.0.0", + "--schemas-dir", + schemasDir, + "--dry-run", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Would publish config/v2.json, lock/v1.json to "); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect.each(refusals)("refuses $description", ({ files, message }) => + Effect.gen(function* () { + const schemasDir = yield* schemasDirOf(files); + + const { exitCode, stdout, stderr } = yield* runScript([ + "--version", + "1.0.0", + "--schemas-dir", + schemasDir, + "--dry-run", + ]); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(message); + }).pipe(Effect.provide(platformLayer)), + ); +}); diff --git a/apps/cli/scripts/publish-schemas.git.e2e.test.ts b/apps/cli/scripts/publish-schemas.git.e2e.test.ts new file mode 100644 index 0000000000..617e08c3c7 --- /dev/null +++ b/apps/cli/scripts/publish-schemas.git.e2e.test.ts @@ -0,0 +1,359 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Config, ConfigProvider, Effect, FileSystem, JsonSchema, Layer, Path } from "effect"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(new URL("./publish-schemas.ts", import.meta.url)); +const platformLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); + +const REPO = "supabase/supabase"; +const PUBLISHED = "apps/www/public/schemas"; +const SEED_IDENTITY = ["-c", "user.name=seed", "-c", "user.email=seed@example.com"]; + +const fakeGh = `#!/usr/bin/env bash +printf '%s' "$*" | tr '\\n' ' ' >> "$FAKE_GH_DIR/calls.log" +echo >> "$FAKE_GH_DIR/calls.log" +if [ "$1 $2" = "pr list" ]; then + cat "$FAKE_GH_DIR/open-pulls.json" +fi +exit 0 +`; + +// Not prettier-formatted, so a formatting step would show up as a byte difference. +const schemaText = (name: string, version: number, marker: string) => + `{"$schema":"${JsonSchema.META_SCHEMA_URI_DRAFT_07}",\n\t"$id":"https://supabase.com/schemas/${name}/v${version}.json", "marker":"${marker}"}`; + +const spawn = (argv: ReadonlyArray, cwd: string, env: Record) => + Effect.gen(function* () { + const child = Bun.spawn([...argv], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }); + +const writeFiles = (directory: string, files: Record) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [file, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(directory, file)), { recursive: true }); + yield* fs.writeFileString(path.join(directory, file), content); + } + }); + +/** + * A local bare repository standing in for supabase/supabase, seeded on `master`, and a schemas + * directory to publish from. `branch` adds a `cli/schemas` branch cut from the seed and + * `targetAfterBranch` a later `master` commit. git reaches the bare repository through env-only URL + * rewriting and `gh` is a recording fake. + */ +const harness = (options: { + readonly target: Record; + readonly local: Record; + readonly branch?: Record; + readonly targetAfterBranch?: Record; + readonly openPulls?: string; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "publish-schemas-" }); + const bare = path.join(root, "supabase.git"); + const seed = path.join(root, "seed"); + const schemasDir = path.join(root, "schemas"); + const bin = path.join(root, "bin"); + const tmp = path.join(root, "tmp"); + + const isolatedGit = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }; + const git = (cwd: string, ...args: ReadonlyArray) => + Effect.gen(function* () { + const result = yield* spawn(["git", ...args], cwd, { ...process.env, ...isolatedGit }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; + }); + + const commitAndPush = (branch: string, message: string, files: Record) => + Effect.gen(function* () { + yield* writeFiles(seed, files); + yield* git(seed, "add", "-A"); + yield* git(seed, ...SEED_IDENTITY, "commit", "--quiet", "-m", message); + yield* git(seed, "push", "--quiet", bare, branch); + }); + + yield* fs.makeDirectory(seed); + yield* fs.makeDirectory(tmp); + yield* git(root, "init", "--quiet", "--bare", "--initial-branch=master", bare); + yield* git(seed, "init", "--quiet", "--initial-branch=master"); + yield* commitAndPush("master", "seed", { "README.md": "# supabase", ...options.target }); + if (options.branch) { + yield* git(seed, "switch", "--quiet", "--create", "cli/schemas"); + yield* commitAndPush("cli/schemas", "branch", options.branch); + yield* git(seed, "switch", "--quiet", "master"); + } + if (options.targetAfterBranch) { + yield* commitAndPush("master", "master moves on", options.targetAfterBranch); + } + + yield* writeFiles(schemasDir, options.local); + yield* writeFiles(bin, { gh: fakeGh }); + yield* fs.chmod(path.join(bin, "gh"), 0o755); + yield* writeFiles(root, { "calls.log": "", "open-pulls.json": options.openPulls ?? "[]" }); + + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + const inheritedPath = yield* Config.String("PATH"); + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...inherited } = process.env; + const env = { + ...inherited, + ...isolatedGit, + PATH: `${bin}:${inheritedPath}`, + FAKE_GH_DIR: root, + TMPDIR: tmp, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${bare}.insteadOf`, + GIT_CONFIG_VALUE_0: `https://github.com/${REPO}.git`, + }; + + return { + publish: spawn( + [bunExecutable, scriptPath, "--version", "1.2.3", "--schemas-dir", schemasDir], + root, + env, + ), + branches: git(bare, "for-each-ref", "--format=%(refname:short)", "refs/heads").pipe( + Effect.map((text) => text.trim().split("\n")), + ), + show: (ref: string, file: string) => git(bare, "show", `${ref}:${PUBLISHED}/${file}`), + revision: (ref: string) => + git(bare, "rev-parse", "--verify", ref).pipe(Effect.map((text) => text.trim())), + commitSubject: (ref: string) => git(bare, "log", "-1", "--format=%an: %s", ref), + leftoverTempEntries: fs.readDirectory(tmp), + ghCalls: fs + .readFileString(path.join(root, "calls.log")) + .pipe(Effect.map((text) => text.split("\n").filter((line) => line !== ""))), + }; + }); + +describe("publish-schemas.ts against a local stand-in for supabase/supabase", () => { + it.live( + "pushes the changed schemas byte for byte, opens a PR and arms auto-merge", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "new"); + const projectConfig = schemaText("project-config", 1, "added"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "old") }, + local: { "config/v1.json": config, "project-config/v1.json": projectConfig }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Pushed cli/schemas to supabase/supabase"); + expect(yield* target.branches).toEqual(["cli/schemas", "master"]); + expect(yield* target.show("cli/schemas", "config/v1.json")).toBe(config); + expect(yield* target.show("cli/schemas", "project-config/v1.json")).toBe(projectConfig); + expect(yield* target.show("master", "config/v1.json")).toBe(schemaText("config", 1, "old")); + expect(yield* target.commitSubject("cli/schemas")).toBe( + "github-actions[bot]: chore: update cli config schemas to v1.2.3\n", + ); + const calls = yield* target.ghCalls; + expect(calls.map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr create", + "pr merge", + ]); + expect(calls[1]).toContain("--title chore: update cli config schemas --body"); + expect(calls[1]).toContain("--base master --head cli/schemas"); + expect(calls[2]).toBe("pr merge cli/schemas --repo supabase/supabase --auto --squash"); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "arms auto-merge on the PR that is already open instead of creating another", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: {}, + local: { "config/v1.json": schemaText("config", 1, "new") }, + openPulls: '[{"number":7,"headRepositoryOwner":{"login":"supabase"}}]', + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Reusing the open PR for cli/schemas"); + expect((yield* target.ghCalls).map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr merge", + ]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "publishes only the current version and leaves lower versions as published", + () => + Effect.gen(function* () { + const published = schemaText("supabase-lock", 1, "published"); + const current = schemaText("supabase-lock", 2, "current"); + const target = yield* harness({ + target: { [`${PUBLISHED}/supabase-lock/v1.json`]: published }, + local: { + "supabase-lock/v1.json": schemaText("supabase-lock", 1, "edited after the freeze"), + "supabase-lock/v2.json": current, + }, + }); + + const { exitCode, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(yield* target.show("cli/schemas", "supabase-lock/v2.json")).toBe(current); + expect(yield* target.show("cli/schemas", "supabase-lock/v1.json")).toBe(published); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "pushes nothing and opens no PR when the target already has identical files", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "same"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: config }, + local: { "config/v1.json": config }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(`${PUBLISHED} is already up to date in supabase/supabase`); + expect(yield* target.branches).toEqual(["master"]); + expect(yield* target.ghCalls).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses to write a version below one supabase.com already serves, pushes nothing and cleans up", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v2.json`]: schemaText("config", 2, "served") }, + local: { "config/v1.json": schemaText("config", 1, "local") }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: supabase/supabase already serves config/v2.json, so writing config/v1.json would modify a frozen version.\n", + ); + expect(yield* target.branches).toEqual(["master"]); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses a version below one master gained after cli/schemas was cut, leaving the branch untouched", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: {}, + branch: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "stale branch") }, + targetAfterBranch: { [`${PUBLISHED}/config/v2.json`]: schemaText("config", 2, "served") }, + local: { "config/v1.json": schemaText("config", 1, "local") }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: supabase/supabase already serves config/v2.json, so writing config/v1.json would modify a frozen version.\n", + ); + expect(yield* target.revision("cli/schemas")).toBe(branchTip); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses to publish when cli/schemas edits a frozen version, leaving the branch untouched", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: { + [`${PUBLISHED}/supabase-lock/v1.json`]: schemaText("supabase-lock", 1, "frozen"), + }, + branch: { + [`${PUBLISHED}/supabase-lock/v1.json`]: schemaText( + "supabase-lock", + 1, + "edited on branch", + ), + }, + local: { + "supabase-lock/v1.json": schemaText("supabase-lock", 1, "frozen"), + "supabase-lock/v2.json": schemaText("supabase-lock", 2, "current"), + }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: cli/schemas also changes supabase-lock/v1.json against master, and only the current schema versions may change. Delete cli/schemas in supabase/supabase and rerun the publish.\n", + ); + expect(yield* target.revision("cli/schemas")).toBe(branchTip); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "publishes over an older edit of a current version as one new commit on the existing branch", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "new"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "published") }, + branch: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "older edit") }, + local: { "config/v1.json": config }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Pushed cli/schemas to supabase/supabase"); + expect(yield* target.show("cli/schemas", "config/v1.json")).toBe(config); + expect(yield* target.revision("cli/schemas^")).toBe(branchTip); + expect((yield* target.ghCalls).map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr create", + "pr merge", + ]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); +}); diff --git a/apps/cli/scripts/publish-schemas.ts b/apps/cli/scripts/publish-schemas.ts new file mode 100644 index 0000000000..203fc5273b --- /dev/null +++ b/apps/cli/scripts/publish-schemas.ts @@ -0,0 +1,215 @@ +/** + * Publishes the current version of each JSON Schema under `apps/docs/public/schemas` to + * supabase.com by opening a PR against supabase/supabase, which serves + * `apps/www/public/schemas` at https://supabase.com/schemas: + * + * bun scripts/publish-schemas.ts --version + * + * Lower versions of a schema are frozen: only the highest `v.json` per schema is written. The + * run refuses when the base branch already serves a higher version, or when the publishing branch + * changes anything other than the current versions. A relative `--schemas-dir` resolves against the + * repository root. A release that changes no schema opens or updates nothing. + */ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { $ } from "bun"; +import { Console, Data, Effect, FileSystem, JsonSchema, Layer, Path, Schema } from "effect"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { publishPullRequest, shell } from "./publish-pull-request.ts"; + +const PUBLISHED_PATH = "apps/www/public/schemas"; +const VERSION_FILE = /^v([1-9]\d*)\.json$/; + +class PublishRefused extends Data.TaggedError("PublishRefused")<{ readonly reason: string }> {} + +interface CurrentSchema { + readonly name: string; + readonly version: number; + readonly file: string; +} + +const decodeDocument = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const { values } = parseArgs({ + options: { + version: { type: "string" }, + repo: { type: "string", default: "supabase/supabase" }, + "schemas-dir": { type: "string", default: "apps/docs/public/schemas" }, + branch: { type: "string", default: "cli/schemas" }, + base: { type: "string", default: "master" }, + "dry-run": { type: "boolean", default: false }, + }, +}); + +const versionsIn = (entries: ReadonlyArray) => + entries.flatMap((entry) => { + const match = VERSION_FILE.exec(entry); + return match?.[1] === undefined ? [] : [Number(match[1])]; + }); + +const listDirectory = (directory: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.exists(directory)) ? (yield* fs.readDirectory(directory)).sort() : []; + }); + +const discoverCurrentSchemas = (schemasDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const current: Array = []; + for (const name of yield* listDirectory(schemasDir)) { + const directory = path.join(schemasDir, name); + if ((yield* fs.stat(directory)).type !== "Directory") continue; + const entries = yield* listDirectory(directory); + if (entries.length === 0) continue; + const misnamed = entries.find((entry) => !VERSION_FILE.test(entry)); + if (misnamed !== undefined) { + return yield* new PublishRefused({ + reason: `${name}/${misnamed} does not match v.json.`, + }); + } + const version = Math.max(...versionsIn(entries)); + current.push({ name, version, file: path.join(directory, `v${version}.json`) }); + } + if (current.length === 0) { + return yield* new PublishRefused({ reason: `no schemas found in ${schemasDir}.` }); + } + return current; + }); + +const validateCurrentSchema = ({ name, version, file }: CurrentSchema) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const label = `${name}/v${version}.json`; + const document = yield* decodeDocument(yield* fs.readFileString(file)).pipe( + Effect.mapError(() => new PublishRefused({ reason: `${label} is not a valid JSON object.` })), + ); + if (document["$schema"] !== JsonSchema.META_SCHEMA_URI_DRAFT_07) { + return yield* new PublishRefused({ + reason: `${label} must declare "$schema": "${JsonSchema.META_SCHEMA_URI_DRAFT_07}".`, + }); + } + const id = `https://supabase.com/schemas/${name}/v${version}.json`; + if (document["$id"] !== id) { + return yield* new PublishRefused({ reason: `${label} must declare "$id": "${id}".` }); + } + }); + +const writeCurrentSchemas = ( + repo: string, + base: string, + branch: string, + schemas: ReadonlyArray, + checkoutDir: string, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const published = (name: string) => path.join(checkoutDir, PUBLISHED_PATH, name); + const prefix = `${PUBLISHED_PATH}/`; + + const listing = yield* shell(() => + $`git -C ${checkoutDir} -c core.quotePath=false ls-tree -r --name-only origin/${base} -- ${PUBLISHED_PATH}`.text(), + ); + const servedPaths = listing + .split("\n") + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length)); + for (const { name, version } of schemas) { + const served = versionsIn( + servedPaths + .filter((file) => file.startsWith(`${name}/`)) + .map((file) => file.slice(name.length + 1)), + ); + const newest = Math.max(version, ...served); + if (newest > version) { + return yield* new PublishRefused({ + reason: `${repo} already serves ${name}/v${newest}.json, so writing ${name}/v${version}.json would modify a frozen version.`, + }); + } + } + for (const { name, version, file } of schemas) { + yield* fs.makeDirectory(published(name), { recursive: true }); + yield* fs.copyFile(file, path.join(published(name), `v${version}.json`)); + } + + yield* shell(() => $`git -C ${checkoutDir} add -- ${PUBLISHED_PATH}`); + const changed = yield* shell(() => + $`git -C ${checkoutDir} -c core.quotePath=false diff --cached --name-only --no-renames origin/${base} -- ${PUBLISHED_PATH}`.text(), + ); + const current = new Set( + schemas.map(({ name, version }) => `${prefix}${name}/v${version}.json`), + ); + const unexpected = changed + .split("\n") + .filter((line) => line !== "" && !current.has(line)) + .map((line) => line.slice(prefix.length)) + .sort(); + if (unexpected.length > 0) { + return yield* new PublishRefused({ + reason: `${branch} also changes ${unexpected.join(", ")} against ${base}, and only the current schema versions may change. Delete ${branch} in ${repo} and rerun the publish.`, + }); + } + }); + +const main = Effect.gen(function* () { + const path = yield* Path.Path; + + const versionArgument = values.version; + if (!versionArgument) { + yield* Console.error( + "Usage: bun scripts/publish-schemas.ts --version [--repo ] [--schemas-dir ] [--branch ] [--base ] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; + + const repo = values.repo!; + const branch = values.branch!; + const base = values.base!; + const schemasDir = path.resolve(import.meta.dir, "../../..", values["schemas-dir"]!); + + const schemas = yield* discoverCurrentSchemas(schemasDir); + yield* Effect.forEach(schemas, validateCurrentSchema); + + if (values["dry-run"]) { + const files = schemas.map(({ name, version }) => `${name}/v${version}.json`).join(", "); + yield* Console.log(`Would publish ${files} to ${repo}:${PUBLISHED_PATH} on branch ${branch}`); + return; + } + + yield* publishPullRequest({ + repo, + base, + branch, + stagedPath: PUBLISHED_PATH, + write: (checkoutDir) => writeCurrentSchemas(repo, base, branch, schemas, checkoutDir), + commitMessage: `chore: update cli config schemas to v${version}`, + pullRequestTitle: "chore: update cli config schemas", + pullRequestBody: [ + "Updates the published JSON Schemas from the supabase/cli release workflow.", + "", + "Generated by `scripts/publish-schemas.ts` in supabase/cli from", + "`apps/docs/public/schemas` — edit the schemas there rather than this folder.", + "Published versions below the current one are frozen and never rewritten.", + "", + "Auto-merge is enabled: the PR squash-merges once a reviewer approves it.", + ].join("\n"), + autoMerge: true, + }); +}); + +await Effect.runPromise( + main.pipe( + Effect.catchTag("PublishRefused", ({ reason }) => + Console.error(`Refusing to publish: ${reason}`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(error.cause ?? error)), + ), +); diff --git a/apps/cli/scripts/publish.ts b/apps/cli/scripts/publish.ts index 226a1f6978..69e996753b 100644 --- a/apps/cli/scripts/publish.ts +++ b/apps/cli/scripts/publish.ts @@ -1,12 +1,12 @@ import { $ } from "bun"; -import { copyFile } from "node:fs/promises"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, Fiber, FileSystem, Layer, Path, Predicate } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/http"; +import { waitUntilInstallable } from "./npm-registry.ts"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "./release-channels.ts"; -const root = path.resolve(import.meta.dir, "../../.."); - const PLATFORM_PACKAGES = [ "cli-darwin-arm64", "cli-darwin-x64", @@ -27,133 +27,209 @@ const { values } = parseArgs({ const dryRun = values["dry-run"]; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error( - `Invalid --tag value: ${String(tag)}. Expected one of: ${PUBLISHABLE_NPM_TAGS_HINT}.`, - ); - process.exit(1); -} - -const cliDir = path.join(root, "apps/cli"); -const cliPkgJson = await Bun.file(path.join(cliDir, "package.json")).json(); -const umbrellaName: string = cliPkgJson.name; -const umbrellaVersion: string = cliPkgJson.version; const dryRunFlag = dryRun ? ["--dry-run"] : []; const tagFlag = ["--tag", tag]; const provenanceFlag = ["--provenance"]; const noGitChecksFlag = ["--no-git-checks"]; -// Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and -// publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). -const registryUrl = (await $`npm config get registry`.quiet().text()).trim().replace(/\/$/, ""); +class PublishStepFailed extends Data.TaggedError("PublishStepFailed")<{ + readonly cause: unknown; +}> {} + +class RegistryProbeFailed extends Data.TaggedError("RegistryProbeFailed")<{ + readonly message: string; +}> {} + +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new PublishStepFailed({ cause }) }); // Used as both a pre-flight skip check and post-failure reconciliation, since a publish can // fail with E403 when the registry CDN cache lags behind an already-succeeded publish. -async function isAlreadyPublished(name: string, version: string): Promise { - const encodedName = name.replace("/", "%2F"); - const res = await fetch(`${registryUrl}/${encodedName}/${version}`, { method: "GET" }); +const isAlreadyPublished = Effect.fnUntraced(function* ( + registryUrl: string, + name: string, + version: string, +) { + const encodedName = name.replaceAll("/", "%2F"); + const res = yield* HttpClient.get(`${registryUrl}/${encodedName}/${version}`); if (res.status === 200) return true; if (res.status === 404) return false; - throw new Error(`npm registry probe for ${name}@${version} returned HTTP ${res.status}`); -} + return yield* new RegistryProbeFailed({ + message: `npm registry probe for ${name}@${version} returned HTTP ${res.status}`, + }); +}); type PublishResult = "published" | "skipped"; // Skips a version already on the registry — checked before publishing and again after a // publish-time conflict — and lets any other failure propagate. -async function publishPackage(opts: { +const publishPackage = Effect.fnUntraced(function* (opts: { + registryUrl: string; name: string; version: string; cwd: string; extraFlags?: string[]; -}): Promise { - const { name, version, cwd, extraFlags = [] } = opts; +}) { + const { registryUrl, name, version, cwd, extraFlags = [] } = opts; const label = `${name}@${version}`; - if (await isAlreadyPublished(name, version)) { - console.log(` [skip] ${label} already published.`); - return "skipped"; + if (yield* isAlreadyPublished(registryUrl, name, version)) { + yield* Console.log(` [skip] ${label} already published.`); + return "skipped" satisfies PublishResult; } - console.log(` Publishing ${label}...`); - try { - await $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( + yield* Console.log(` Publishing ${label}...`); + return yield* step(() => + $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( cwd, + ), + ).pipe( + Effect.andThen(Console.log(` ${label} published.`)), + Effect.as("published"), + Effect.catchTag("PublishStepFailed", (error) => + Effect.gen(function* () { + if (yield* isAlreadyPublished(registryUrl, name, version)) { + yield* Console.log( + ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, + ); + return "skipped" satisfies PublishResult; + } + return yield* error; + }), + ), + ); +}); + +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected one of: ${PUBLISHABLE_NPM_TAGS_HINT}.`, ); - console.log(` ${label} published.`); - return "published"; - } catch (error) { - if (await isAlreadyPublished(name, version)) { - console.log( - ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, - ); - return "skipped"; - } - throw error; + return yield* Effect.sync(() => process.exit(1)); } -} -console.log( - dryRun - ? `Publishing to npm with tag "${tag}" (dry run)...\n` - : `Publishing to npm with tag "${tag}"...\n`, -); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); + const cliDir = path.join(root, "apps/cli"); + const cliPkgJson = yield* step(() => Bun.file(path.join(cliDir, "package.json")).json()); + const umbrellaName: string = cliPkgJson.name; + const umbrellaVersion: string = cliPkgJson.version; + + // Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and + // publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). + const registryUrl = (yield* step(() => $`npm config get registry`.quiet().text())) + .trim() + .replace(/\/$/, ""); + + yield* Console.log( + dryRun + ? `Publishing to npm with tag "${tag}" (dry run)...\n` + : `Publishing to npm with tag "${tag}"...\n`, + ); -// Every platform package must already be at the umbrella version; `sync-versions.ts` runs -// before this in the release workflow, so a mismatch means the script ran out of order. -for (const pkg of PLATFORM_PACKAGES) { - const pkgJson = await Bun.file(path.join(root, "packages", pkg, "package.json")).json(); - if (pkgJson.version !== umbrellaVersion) { - console.error( - `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${umbrellaVersion}. Run sync-versions.ts first.`, + // Every platform package must already be at the umbrella version; `sync-versions.ts` runs + // before this in the release workflow, so a mismatch means the script ran out of order. + for (const pkg of PLATFORM_PACKAGES) { + const pkgJson = yield* step(() => + Bun.file(path.join(root, "packages", pkg, "package.json")).json(), ); - process.exit(1); + if (pkgJson.version !== umbrellaVersion) { + yield* Console.error( + `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${umbrellaVersion}. Run sync-versions.ts first.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } } -} -console.log("Publishing platform packages..."); -const platformResults = await Promise.all( - PLATFORM_PACKAGES.map((pkg) => - publishPackage({ - name: `@supabase/${pkg}`, - version: umbrellaVersion, - cwd: path.join(root, "packages", pkg), - extraFlags: ["--access", "public"], - }), - ), -); + yield* Console.log("Publishing platform packages..."); + /* Detached so a failed job fails the run without cancelling its siblings; callers observe + completion with Fiber.joinAll. */ + const platformResults = yield* Fiber.joinAll( + yield* Effect.forEach(PLATFORM_PACKAGES, (pkg) => + publishPackage({ + registryUrl, + name: `@supabase/${pkg}`, + version: umbrellaVersion, + cwd: path.join(root, "packages", pkg), + extraFlags: ["--access", "public"], + }).pipe(Effect.forkDetach({ startImmediately: true })), + ), + ); -console.log("\nBuilding umbrella package shim..."); -await $`pnpm build:shim`.cwd(cliDir); + // npm makes large tarballs installable minutes after the PUT returns, so the umbrella must wait + // for its binaries or installs in that window get no binary. + if (!dryRun) { + yield* Console.log("\nWaiting for platform packages to become installable..."); + yield* waitUntilInstallable({ + registryUrl, + packages: PLATFORM_PACKAGES.map((pkg) => ({ + name: `@supabase/${pkg}`, + version: umbrellaVersion, + })), + onInstallable: ({ name, version }) => Console.log(` ${name}@${version} is installable.`), + }); + } -// npm renders the README from the package directory; apps/cli/README.md documents the source -// layout for contributors, not what npm users should see, so copy the root README over it. -console.log("\nStaging root README for umbrella package..."); -await copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); + yield* Console.log("\nBuilding umbrella package shim..."); + yield* step(() => $`pnpm build:shim`.cwd(cliDir)); + + // npm renders the README from the package directory; apps/cli/README.md documents the source + // layout for contributors, not what npm users should see, so copy the root README over it. + yield* Console.log("\nStaging root README for umbrella package..."); + yield* fs.copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); + + yield* Console.log(`Publishing umbrella package ${umbrellaName}...`); + const umbrellaResult = yield* publishPackage({ + registryUrl, + name: umbrellaName, + version: umbrellaVersion, + cwd: cliDir, + }); + + const results = [...platformResults, umbrellaResult]; + const publishedCount = results.filter((r) => r === "published").length; + const skippedCount = results.filter((r) => r === "skipped").length; + + yield* Console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); + + // All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that + // today's commits silently didn't ship a new version. Warn loudly so a human can decide whether + // to re-cut via workflow_dispatch. + if (publishedCount === 0) { + yield* Console.warn( + `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + + ` If today's commits were expected to ship, the version did not advance.\n` + + ` Re-cut as a fresh version via the Release workflow (workflow_dispatch).`, + ); + } -console.log(`Publishing umbrella package ${umbrellaName}...`); -const umbrellaResult = await publishPackage({ - name: umbrellaName, - version: umbrellaVersion, - cwd: cliDir, + yield* Console.log("\nAll packages published successfully."); }); -const results = [...platformResults, umbrellaResult]; -const publishedCount = results.filter((r) => r === "published").length; -const skippedCount = results.filter((r) => r === "skipped").length; - -console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); - -// All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that -// today's commits silently didn't ship a new version. Warn loudly so a human can decide whether -// to re-cut via workflow_dispatch. -if (publishedCount === 0) { - console.warn( - `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + - ` If today's commits were expected to ship, the version did not advance.\n` + - ` Re-cut as a fresh version via the Release workflow (workflow_dispatch).`, - ); +/* A registry URL that cannot be parsed fails as `fetch` reports an invalid URL. */ +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + if ( + Predicate.isTagged(error, "HttpClientError") && + Predicate.hasProperty(error, "reason") && + Predicate.isTagged(error.reason, "InvalidUrlError") + ) { + return Object.assign(new TypeError("fetch() URL is invalid"), { code: "ERR_INVALID_URL" }); + } + return error.cause !== undefined ? error.cause : new Error(error.message); } -console.log("\nAll packages published successfully."); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide( + Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ), + ), + ), +); diff --git a/apps/cli/scripts/published-schemas.ts b/apps/cli/scripts/published-schemas.ts new file mode 100644 index 0000000000..31f2ca2073 --- /dev/null +++ b/apps/cli/scripts/published-schemas.ts @@ -0,0 +1,77 @@ +import { HOSTED_LOCK_SCHEMA_URL, Lock, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { JsonSchema, Predicate, Schema } from "effect"; + +const DEFINITION_REF_PREFIX = "#/definitions/"; + +const renameRefs = (node: unknown, renames: ReadonlyMap): unknown => + Array.isArray(node) + ? node.map((child) => renameRefs(child, renames)) + : Predicate.isObject(node) + ? renameRefsIn(node, renames) + : node; + +const renameRefsIn = ( + node: JsonSchema.JsonSchema, + renames: ReadonlyMap, +): JsonSchema.JsonSchema => + Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + key === "$ref" && typeof value === "string" + ? (renames.get(value) ?? value) + : renameRefs(value, renames), + ]), + ); + +/** Collapses identical definitions onto the first name, since `Lock` renders one per service. */ +const dedupeDefinitions = ({ schema, definitions }: JsonSchema.Document<"draft-07">) => { + const firstByBody = new Map(); + const renames = new Map(); + const kept: Array<[string, JsonSchema.JsonSchema]> = []; + for (const [name, definition] of Object.entries(definitions)) { + const body = JSON.stringify(definition); + const first = firstByBody.get(body); + if (first === undefined) { + firstByBody.set(body, name); + kept.push([name, definition]); + } else { + renames.set(`${DEFINITION_REF_PREFIX}${name}`, `${DEFINITION_REF_PREFIX}${first}`); + } + } + return { + schema: renameRefsIn(schema, renames), + definitions: renameRefsIn(Object.fromEntries(kept), renames), + }; +}; + +/** `readLock` accepts the hosted `$schema` URL alongside the legacy one that writes still use. */ +const acceptHostedSchemaUrl = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { + const properties = schema["properties"]; + return Predicate.isObject(properties) + ? { + ...schema, + properties: { + ...properties, + $schema: { type: "string", enum: [LOCK_SCHEMA_URL, HOSTED_LOCK_SCHEMA_URL] }, + }, + } + : schema; +}; + +/** The draft-07 JSON Schema of `supabase/supabase.lock`, rendered from the stack's `Lock` schema. */ +export const lockSchemaDocument = () => { + const { schema, definitions } = dedupeDefinitions( + JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(Lock)), + ); + return { + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, + $id: HOSTED_LOCK_SCHEMA_URL, + title: "supabase.lock", + description: "The service versions that supabase start pins in supabase/supabase.lock.", + ...acceptHostedSchemaUrl(schema), + definitions, + }; +}; + +/** The path under `apps/docs/public` that serves the schema published at `url`. */ +export const publishedSchemaPath = (url: string): string => new URL(url).pathname; diff --git a/apps/cli/scripts/published-schemas.unit.test.ts b/apps/cli/scripts/published-schemas.unit.test.ts new file mode 100644 index 0000000000..ee0e98bdab --- /dev/null +++ b/apps/cli/scripts/published-schemas.unit.test.ts @@ -0,0 +1,214 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + CLI_CONFIG_SCHEMA_URL, + PROJECT_CONFIG_SCHEMA_URL, + toCliConfigJsonSchema, + toProjectConfigJsonSchema, +} from "@supabase/config"; +import { HOSTED_LOCK_SCHEMA_URL, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { Effect, FileSystem, JsonSchema, Path, Schema } from "effect"; +import { lockSchemaDocument, publishedSchemaPath } from "./published-schemas.ts"; + +type Document = Readonly>; + +const REGENERATE = + "run `pnpm --filter @supabase/config build && bun apps/cli/scripts/generate-docs.ts`"; + +const POST_DRAFT_07_KEYWORDS = new Set([ + "$defs", + "prefixItems", + "unevaluatedProperties", + "unevaluatedItems", + "dependentRequired", + "dependentSchemas", + "$dynamicRef", + "$dynamicAnchor", + "$recursiveRef", + "$recursiveAnchor", + "$anchor", + "$vocabulary", + "minContains", + "maxContains", +]); +const DATA_KEYWORDS = new Set(["default", "examples", "enum", "const"]); +const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties", "definitions"]); + +const isNode = (value: unknown): value is Document => + typeof value === "object" && value !== null && !Array.isArray(value); + +const findPostDraft07Usage = (node: unknown, path: ReadonlyArray = []): Array => { + if (Array.isArray(node)) { + return node.flatMap((child, index) => findPostDraft07Usage(child, [...path, String(index)])); + } + if (!isNode(node)) return []; + return Object.entries(node).flatMap(([keyword, value]) => { + const here = [...path, keyword]; + if (POST_DRAFT_07_KEYWORDS.has(keyword)) return [here.join("/")]; + if (keyword === "$ref" && typeof value === "string" && value.startsWith("#/$defs/")) { + return [here.join("/")]; + } + if (DATA_KEYWORDS.has(keyword)) return []; + if (SCHEMA_MAP_KEYWORDS.has(keyword) && isNode(value)) { + return Object.entries(value).flatMap(([name, child]) => + findPostDraft07Usage(child, [...here, name]), + ); + } + return findPostDraft07Usage(value, here); + }); +}; + +const collectRefs = (node: unknown): Array => { + if (Array.isArray(node)) return node.flatMap(collectRefs); + if (!isNode(node)) return []; + return Object.entries(node).flatMap(([keyword, value]) => + keyword === "$ref" && typeof value === "string" ? [value] : collectRefs(value), + ); +}; + +const withoutMetadata = ({ + $id: _id, + title: _title, + description: _description, + ...rest +}: Document): Document => rest; + +const published = [ + { + name: "config", + file: "schemas/config/v1.json", + url: CLI_CONFIG_SCHEMA_URL, + build: (): Document => toCliConfigJsonSchema(), + comparable: withoutMetadata, + }, + { + name: "project-config", + file: "schemas/project-config/v1.json", + url: PROJECT_CONFIG_SCHEMA_URL, + build: (): Document => toProjectConfigJsonSchema(), + comparable: withoutMetadata, + }, + { + name: "supabase-lock", + file: "schemas/supabase-lock/v1.json", + url: HOSTED_LOCK_SCHEMA_URL, + build: (): Document => lockSchemaDocument(), + comparable: (document: Document): Document => document, + }, +] as const; + +const decodeDocument = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const readCommitted = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const publicDir = path.resolve(import.meta.dirname, "../../docs/public"); + return yield* decodeDocument(yield* fs.readFileString(path.join(publicDir, file))); + }).pipe(Effect.provide(BunServices.layer)); + +describe("findPostDraft07Usage", () => { + it("flags post-draft-07 keywords and $defs refs in schema positions", () => { + const document = { + type: "object", + $defs: { Name: { type: "string" } }, + properties: { + tuple: { type: "array", prefixItems: [{ type: "string" }] }, + name: { $ref: "#/$defs/Name" }, + }, + }; + + expect(findPostDraft07Usage(document)).toEqual([ + "$defs", + "properties/tuple/prefixItems", + "properties/name/$ref", + ]); + }); + + it("treats property names and data keywords as data", () => { + const document = { + type: "object", + properties: { + prefixItems: { type: "string", default: "x" }, + $defs: { type: "object", default: { $defs: {} }, examples: [{ prefixItems: [] }] }, + mode: { enum: ["$defs", "prefixItems"], const: "$defs" }, + }, + definitions: { unevaluatedProperties: { type: "string" } }, + }; + + expect(findPostDraft07Usage(document)).toEqual([]); + }); +}); + +describe.each(published)("published $name schema", (entry) => { + it.effect("declares draft-07 and an $id that serves from its own path", () => + Effect.gen(function* () { + const document = yield* readCommitted(entry.file); + + expect(document["$schema"]).toBe(JsonSchema.META_SCHEMA_URI_DRAFT_07); + expect(document["$id"]).toBe(entry.url); + expect(publishedSchemaPath(entry.url)).toBe(`/${entry.file}`); + }), + ); + + it.effect("uses no keyword newer than draft-07", () => + Effect.gen(function* () { + expect(findPostDraft07Usage(yield* readCommitted(entry.file))).toEqual([]); + }), + ); + + it.effect("matches what the build renders", () => + Effect.gen(function* () { + const document = yield* readCommitted(entry.file); + + expect( + entry.comparable(document), + `apps/docs/public/${entry.file} is stale: ${REGENERATE}`, + ).toEqual(entry.comparable(entry.build())); + }), + ); +}); + +describe("published supabase-lock schema definitions", () => { + const readLockDefinitions = Effect.gen(function* () { + const document = yield* readCommitted("schemas/supabase-lock/v1.json"); + const definitions = document["definitions"]; + return { document, definitions: isNode(definitions) ? definitions : {} }; + }); + + it.effect("are pairwise distinct", () => + Effect.gen(function* () { + const { definitions } = yield* readLockDefinitions; + const bodies = Object.values(definitions).map((definition) => JSON.stringify(definition)); + + expect(bodies.length).toBeGreaterThan(1); + expect(new Set(bodies).size).toBe(bodies.length); + }), + ); + + it.effect("cover every $ref in the document", () => + Effect.gen(function* () { + const { document, definitions } = yield* readLockDefinitions; + const refs = collectRefs(document); + + expect(refs).not.toEqual([]); + expect( + refs.filter((ref) => !Object.keys(definitions).includes(ref.replace("#/definitions/", ""))), + ).toEqual([]); + }), + ); + + it.effect("accept every $schema value readLock accepts", () => + Effect.gen(function* () { + const document = yield* readCommitted("schemas/supabase-lock/v1.json"); + const properties = document["properties"]; + const schemaProperty = isNode(properties) ? properties["$schema"] : undefined; + + expect(isNode(schemaProperty) ? schemaProperty["enum"] : undefined).toEqual( + expect.arrayContaining([LOCK_SCHEMA_URL, HOSTED_LOCK_SCHEMA_URL]), + ); + }), + ); +}); diff --git a/apps/cli/scripts/refresh-e2e-timings.ts b/apps/cli/scripts/refresh-e2e-timings.ts index a52667501c..366a50f17b 100644 --- a/apps/cli/scripts/refresh-e2e-timings.ts +++ b/apps/cli/scripts/refresh-e2e-timings.ts @@ -1,12 +1,12 @@ // Rewrites apps/cli/tests/e2e-timings.json from the Vitest results caches that the test-e2e // workflow job uploads as `e2e-timings-` artifacts. -import { spawnSync } from "node:child_process"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { constants } from "node:os"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Path, Predicate, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { version as installedVitestVersion } from "vitest/node"; export interface VitestFileResult { @@ -96,75 +96,278 @@ function isResultsCache(value: unknown): value is VitestResultsCache { ); } -function readResultsCaches(dir: string): VitestResultsCache[] { - return readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name === "results.json") - .map((entry) => { - const file = join(entry.parentPath, entry.name); - const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - if (!isResultsCache(parsed)) { - throw new Error(`${file} is not a Vitest results cache`); +class RefreshTimingsError extends Data.TaggedError("RefreshTimingsError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const platformMessage = (error: { readonly message: string; readonly cause?: unknown }) => + error.cause instanceof Error ? error.cause.message : error.message; + +const toRefreshTimingsError = (error: { readonly message: string; readonly cause?: unknown }) => + new RefreshTimingsError({ message: platformMessage(error), cause: error }); + +const dieWithHostError = (error: { readonly cause?: unknown }) => Effect.die(error.cause ?? error); + +const encodeTimingsJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + +const receivedSignal = (error: { readonly message: string; readonly cause?: unknown }) => + /^Process interrupted due to receipt of signal: '(.+)'$/.exec(platformMessage(error))?.[1]; + +const hostCode = (error: { readonly cause?: unknown }) => + Predicate.hasProperty(error.cause, "code") ? error.cause.code : undefined; + +/** + * Lists the regular files under `dir` breadth first, in directory order, following symlinked + * directories but never counting a symlink as a file. + * Only `fs.readLink`'s "not a symlink" answer (`EINVAL`) marks a plain entry; a link, or one + * `readLink` is denied on (a mode-000 link on macOS), is a symlink. A directory that cannot be + * listed fails the whole listing, reported against `dir`. + */ +const listRegularFiles = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files: string[] = []; + const directories = [{ path: dir, viaSymlink: false }]; + for (let next = directories.shift(); next !== undefined; next = directories.shift()) { + const current = next; + const names = yield* fs.readDirectory(current.path).pipe( + Effect.catch((error) => { + const code = hostCode(error); + return current.viaSymlink && (code === "ENOTDIR" || code === "ENOENT") + ? Effect.succeed([]) + : Effect.fail( + new RefreshTimingsError({ + message: platformMessage(error).replace(`'${current.path}'`, `'${dir}'`), + cause: error, + }), + ); + }), + ); + for (const name of names) { + const entry = path.join(current.path, name); + const isSymlink = yield* fs.readLink(entry).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => Effect.succeed(hostCode(error) !== "EINVAL")), + ); + if (isSymlink) { + directories.push({ path: entry, viaSymlink: true }); + continue; + } + const type = yield* fs.stat(entry).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => undefined), + ); + if (type === "Directory") { + directories.push({ path: entry, viaSymlink: false }); + } else if (type === "File") { + files.push(entry); + } } - return parsed; - }); + } + return files; + }); + +/* Keeps a leading byte order mark, which the default decoder drops, so the JSON parse sees it. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + +/* A plain parse, so malformed JSON reports the parser's own message. */ +function parseJson(text: string): unknown { + return JSON.parse(text); } -const usage = `Usage: pnpm exec bun apps/cli/scripts/refresh-e2e-timings.ts --run [--repo supabase/cli] +const readResultsCaches = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const caches: VitestResultsCache[] = []; + for (const file of yield* listRegularFiles(dir)) { + if (path.basename(file) !== "results.json") { + continue; + } + const text = utf8.decode(yield* fs.readFile(file)); + const parsed = yield* Effect.try({ + try: () => parseJson(text), + catch: (cause) => + new RefreshTimingsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + if (!isResultsCache(parsed)) { + return yield* new RefreshTimingsError({ message: `${file} is not a Vitest results cache` }); + } + caches.push(parsed); + } + return caches; + }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(toRefreshTimingsError(error)))); + +const usage = `Usage: bun apps/cli/scripts/refresh-e2e-timings.ts --run [--repo supabase/cli] Downloads the e2e-timings-* artifacts of a test workflow run and rewrites apps/cli/tests/e2e-timings.json from them.`; +const refresh = (run: string, repo: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const downloadDir = yield* Effect.acquireRelease( + fs.makeTempDirectory({ prefix: "e2e-timings-" }).pipe(Effect.catch(dieWithHostError)), + (dir) => + fs.remove(dir, { recursive: true, force: true }).pipe(Effect.catch(dieWithHostError)), + ); + const program = Effect.gen(function* () { + const exitCode = yield* Effect.scoped( + Effect.gen(function* () { + const download = yield* spawner + .spawn( + ChildProcess.make( + "gh", + [ + "run", + "download", + run, + "--repo", + repo, + "--pattern", + "e2e-timings-*", + "--dir", + downloadDir, + ], + { detached: false, stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + ), + ) + .pipe( + Effect.mapError( + (error) => + new RefreshTimingsError({ + message: `could not run gh: ${platformMessage(error)}`, + cause: error, + }), + ), + Effect.catchDefect((defect) => + Effect.fail( + new RefreshTimingsError({ + message: `could not run gh: ${defect instanceof Error ? defect.message : String(defect)}`, + cause: defect, + }), + ), + ), + ); + return yield* download.exitCode.pipe( + Effect.mapError((error) => { + const signal = receivedSignal(error); + return new RefreshTimingsError({ + message: + signal === undefined + ? `gh run download failed: ${platformMessage(error)}` + : `gh run download exited with signal ${signal}`, + cause: error, + }); + }), + ); + }), + ); + if (exitCode !== 0) { + return yield* new RefreshTimingsError({ + message: `gh run download exited with ${exitCode}`, + }); + } + + const caches = yield* readResultsCaches(downloadDir); + if (caches.length === 0) { + return yield* new RefreshTimingsError({ + message: `no results.json found in the e2e-timings-* artifacts of run ${run}`, + }); + } + const timings = yield* Console.consoleWith((out) => + Effect.try({ + try: () => + mergeTimings(caches, { + vitestVersion: installedVitestVersion, + warn: (message) => out.error(message), + }), + catch: (cause) => + new RefreshTimingsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ); + const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); + const json = yield* encodeTimingsJson({ version: 1, files: timings }); + yield* fs.writeFileString(target, `${json}\n`).pipe(Effect.mapError(toRefreshTimingsError)); + yield* Console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); + }); + yield* program.pipe( + Effect.catch((error) => + Console.error(`error: ${error.message}`).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 1; + }), + ), + ), + ), + ); + }).pipe(Effect.scoped); + +const isSignal = (name: string): name is NodeJS.Signals => Object.hasOwn(constants.signals, name); + +/** + * Holds every signal that already has a handler (Vitest's exit hook) until the run, cleanup + * included, has finished, then raises each held signal again, in arrival order. + */ +const holdSignals = (run: Effect.Effect) => + Effect.gen(function* () { + const received: Array = []; + const hold = (signal: NodeJS.Signals) => { + received.push(signal); + }; + const signals = Object.keys(constants.signals) + .filter(isSignal) + .filter((signal) => process.listenerCount(signal) > 0); + const originalListeners = signals.map((signal) => ({ + signal, + listeners: process.rawListeners(signal), + })); + yield* Effect.acquireRelease( + Effect.sync(() => { + for (const { signal, listeners } of originalListeners) { + process.on(signal, hold); + for (const listener of listeners) { + process.off(signal, listener); + } + } + }), + () => + Effect.sync(() => { + for (const { signal, listeners } of originalListeners) { + for (const listener of listeners) { + process.on(signal, listener); + } + process.off(signal, hold); + } + for (const signal of received) { + process.kill(process.pid, signal); + } + }), + ); + return yield* run; + }).pipe(Effect.scoped); + if (import.meta.main) { const { values } = parseArgs({ args: process.argv.slice(2), options: { run: { type: "string" }, repo: { type: "string", default: "supabase/cli" } }, }); if (!values.run) { - console.error(usage); + await Effect.runPromise(Console.error(usage)); process.exit(2); } - const downloadDir = mkdtempSync(join(tmpdir(), "e2e-timings-")); - try { - const download = spawnSync( - "gh", - [ - "run", - "download", - values.run, - "--repo", - values.repo, - "--pattern", - "e2e-timings-*", - "--dir", - downloadDir, - ], - { stdio: "inherit" }, - ); - if (download.error !== undefined) { - throw new Error(`could not run gh: ${download.error.message}`); - } - if (download.status !== 0) { - throw new Error( - `gh run download exited with ${download.status ?? `signal ${download.signal}`}`, - ); - } - - const caches = readResultsCaches(downloadDir); - if (caches.length === 0) { - throw new Error(`no results.json found in the e2e-timings-* artifacts of run ${values.run}`); - } - const timings = mergeTimings(caches, { - vitestVersion: installedVitestVersion, - warn: (message) => console.error(message), - }); - const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); - writeFileSync(target, `${JSON.stringify({ version: 1, files: timings }, null, 2)}\n`); - console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); - } catch (cause) { - console.error(`error: ${cause instanceof Error ? cause.message : String(cause)}`); - process.exitCode = 1; - } finally { - rmSync(downloadDir, { recursive: true, force: true }); - } + await Effect.runPromise( + holdSignals(refresh(values.run, values.repo).pipe(Effect.provide(BunServices.layer))), + ); } diff --git a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts index 7a3381fd86..3fb8e7bae2 100644 --- a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts @@ -6,7 +6,7 @@ import { renderDockerfile } from "./render-service-dockerfile.ts"; // one-line-per-alias check is satisfied by default; each test only mutates what it's // exercising. `vi.mock` factories are hoisted above every other top-level statement, so the // fixture pins are built inline here rather than imported from an outer module. -vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { +vi.mock("@supabase/stack/internal/artifacts", (importOriginal) => { const nativePin = { archive: "a".repeat(64), manifest: "b".repeat(64) }; const natives = { "darwin-arm64": nativePin, "linux-amd64": nativePin, "linux-arm64": nativePin }; const pin = (sourceService: string, upstreamImage: string, isDefault: boolean) => ({ @@ -21,8 +21,8 @@ vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { natives, }, }); - return { - ...(await importOriginal()), + return importOriginal().then((actual) => ({ + ...actual, catalogPins: () => [ pin("postgres", "supabase/postgres:17.0.0", true), pin("postgres", "supabase/postgres:15.0.0", false), @@ -40,7 +40,7 @@ vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { pin("storage", "supabase/storage-api:v1.0.0", true), pin("analytics", "supabase/logflare:1.0.0", true), ], - }; + })); }); /** A minimal Dockerfile with every slim-capable alias, plus kong and pg14 (hand-managed). */ diff --git a/apps/cli/scripts/render-service-dockerfile.ts b/apps/cli/scripts/render-service-dockerfile.ts index 79327facf3..e853a57189 100644 --- a/apps/cli/scripts/render-service-dockerfile.ts +++ b/apps/cli/scripts/render-service-dockerfile.ts @@ -1,11 +1,10 @@ // Rewrites the slim-capable `FROM ... AS ` lines of -// apps/cli/src/shared/services/Dockerfile (and its byte-identical Go copy, -// apps/cli-go/pkg/config/templates/Dockerfile) in place, from the stack catalog +// apps/cli/src/shared/services/Dockerfile in place, from the stack catalog // (packages/stack/src/Artifacts.ts): each such line's tag comes from that service's catalog pin // `upstreamImage`. Every other line — comments, kong, the Postgres 14 pin (no slim build), and // the one-shot job images — is hand- or Dependabot-managed and left byte-for-byte untouched. // -// bun apps/cli/scripts/render-service-dockerfile.ts # writes both files +// bun apps/cli/scripts/render-service-dockerfile.ts # writes the file // bun apps/cli/scripts/render-service-dockerfile.ts --check # fails on drift, writes nothing // // The `--check` mode is what CI runs (as a `render-service-dockerfile.unit.test.ts` assertion) to @@ -16,15 +15,13 @@ import { isOrioledbVersion, type ArtifactPin, } from "@supabase/stack/internal/artifacts"; +import { Console, Data, Effect } from "effect"; // Resolved from this module's own URL, so both the CLI invocation (cwd = repo root) and the // vitest unit test (cwd = apps/cli) read the same files. export const TS_DOCKERFILE_PATH = fileURLToPath( new URL("../src/shared/services/Dockerfile", import.meta.url), ); -export const GO_DOCKERFILE_PATH = fileURLToPath( - new URL("../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), -); type CatalogEntry = ReturnType[number]; @@ -160,43 +157,43 @@ export function renderDockerfile(currentDockerfile: string): string { return lines.join("\n"); } -async function main(argv: ReadonlyArray): Promise { +class RenderStepFailed extends Data.TaggedError("RenderStepFailed")<{ readonly cause: unknown }> {} + +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new RenderStepFailed({ cause }) }); + +const main = Effect.fnUntraced(function* (argv: ReadonlyArray) { const check = argv.includes("--check"); - const current = await Bun.file(TS_DOCKERFILE_PATH).text(); - const rendered = renderDockerfile(current); + const current = yield* step(() => Bun.file(TS_DOCKERFILE_PATH).text()); + const rendered = yield* Effect.try({ + try: () => renderDockerfile(current), + catch: (cause) => new RenderStepFailed({ cause }), + }); if (check) { - const go = await Bun.file(GO_DOCKERFILE_PATH).text(); - let failed = false; if (current !== rendered) { - console.log( + yield* Console.log( `::error ::${TS_DOCKERFILE_PATH} has drifted from packages/stack/src/Artifacts.ts. Run ` + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", ); - failed = true; + return yield* Effect.sync(() => process.exit(1)); } - if (go !== rendered) { - console.log( - `::error ::${GO_DOCKERFILE_PATH} is not a byte copy of ${TS_DOCKERFILE_PATH}. Run ` + - "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", - ); - failed = true; - } - if (failed) { - process.exit(1); - } - console.log("Both Dockerfiles match the catalog."); + yield* Console.log("The Dockerfile matches the catalog."); return; } - await Bun.write(TS_DOCKERFILE_PATH, rendered); - await Bun.write(GO_DOCKERFILE_PATH, rendered); - console.log(`Regenerated ${TS_DOCKERFILE_PATH} and ${GO_DOCKERFILE_PATH} from the catalog.`); -} + yield* step(() => Bun.write(TS_DOCKERFILE_PATH, rendered)); + yield* Console.log(`Regenerated ${TS_DOCKERFILE_PATH} from the catalog.`); +}); if (import.meta.main) { - main(process.argv.slice(2)).catch((error: unknown) => { - console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - }); + await Effect.runPromise( + main(process.argv.slice(2)).pipe( + Effect.catch(({ cause }) => + Console.log(`::error ::${cause instanceof Error ? cause.message : String(cause)}`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), + ), + ); } diff --git a/apps/cli/scripts/render-service-dockerfile.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.unit.test.ts index 029a235af8..b7ec382d00 100644 --- a/apps/cli/scripts/render-service-dockerfile.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.unit.test.ts @@ -1,15 +1,16 @@ -import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; -import { - GO_DOCKERFILE_PATH, - TS_DOCKERFILE_PATH, - renderDockerfile, -} from "./render-service-dockerfile.ts"; +import { TS_DOCKERFILE_PATH, renderDockerfile } from "./render-service-dockerfile.ts"; + +/* Decoded as UTF-8 with a leading byte order mark kept (`.text()` would drop it). */ +const readDockerfile = (path: string) => + Bun.file(path) + .bytes() + .then((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)); // Both against the real, checked-in Dockerfile and the real catalog — no hardcoded version // literal, so a catalog bump never makes this drift-detection test itself go stale. -const currentTsDockerfile = readFileSync(TS_DOCKERFILE_PATH, "utf8"); +const currentTsDockerfile = await readDockerfile(TS_DOCKERFILE_PATH); describe("renderDockerfile against the real catalog and Dockerfile", () => { test("round-trips: rewriting the checked-in Dockerfile reproduces it byte for byte (no drift)", () => { @@ -20,13 +21,6 @@ describe("renderDockerfile against the real catalog and Dockerfile", () => { expect(renderDockerfile(currentTsDockerfile)).toBe(renderDockerfile(currentTsDockerfile)); }); - // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed yet; - // this is the single place that keeps the two copies in sync until apps/cli-go is deleted. - test("the Go tree's embedded Dockerfile is a byte copy of the TS-owned one", () => { - const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); - expect(goDockerfile).toBe(currentTsDockerfile); - }); - test("detects drift when a generated line is hand-edited", () => { // A hand-edit to a generated tag (bumping gotrue without touching the catalog) must not // round-trip back to itself — this is the drift check's whole point. diff --git a/apps/cli/scripts/replay-control.integration.test.ts b/apps/cli/scripts/replay-control.integration.test.ts new file mode 100644 index 0000000000..17149e5485 --- /dev/null +++ b/apps/cli/scripts/replay-control.integration.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { FetchHttpClient } from "effect/http"; +import * as HttpClient from "effect/http/HttpClient"; +import { startReplayServer } from "../../cli-e2e/src/server/replay-server.ts"; +import { readRequestLogCount, resetReplay } from "./replay-control.ts"; + +const fixturesDir = new URL("../../cli-e2e/fixtures", import.meta.url).pathname; +const SCENARIO = "projects-list-renders-project-list"; + +const replayServer = startReplayServer({ fixturesDir, record: false, stagingUrl: undefined }); + +const callApi = (url: string, path: string) => + HttpClient.HttpClient.use((client) => client.get(new URL(path, url).toString())); + +describe("replay control against the replay server", () => { + it.effect("serves a consumed scenario again and starts each launch with an empty log", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* replayServer; + + yield* resetReplay(server.url, SCENARIO); + const first = yield* callApi(server.url, "/v1/projects"); + const logged = yield* readRequestLogCount(server.url); + + yield* resetReplay(server.url, SCENARIO); + const cleared = yield* readRequestLogCount(server.url); + const second = yield* callApi(server.url, "/v1/projects"); + + expect([first.status, second.status]).toEqual([200, 200]); + expect(logged).toBe(1); + expect(cleared).toBe(0); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("fails when the scenario does not exist instead of keeping the previous one", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* replayServer; + + const failure = yield* resetReplay(server.url, "no-such-scenario").pipe(Effect.flip); + + expect(failure._tag).toBe("ReplayControlError"); + expect(failure.step).toBe("POST /_ctrl/scenario"); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("fails when the server is unreachable", () => + Effect.gen(function* () { + const failure = yield* resetReplay("http://127.0.0.1:1", undefined).pipe(Effect.flip); + + expect(failure._tag).toBe("ReplayControlError"); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); +}); diff --git a/apps/cli/scripts/replay-control.ts b/apps/cli/scripts/replay-control.ts new file mode 100644 index 0000000000..b8b72e052b --- /dev/null +++ b/apps/cli/scripts/replay-control.ts @@ -0,0 +1,80 @@ +/** + * Client for the replay server's `/_ctrl/*` HTTP API, used by `bench-cli.ts` to start each launch + * from a known server state. Any failed control call aborts the benchmark, because a launch that + * runs against stale or missing scenario state measures the wrong work. + */ +import { Effect, Schema } from "effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +export class ReplayControlError extends Schema.TaggedError()( + "ReplayControlError", + { step: Schema.String, message: Schema.String }, +) {} + +export interface ControlCall { + readonly method: "DELETE" | "POST"; + readonly path: string; + readonly body?: { readonly name: string }; +} + +/** The calls that clear the request log and select `scenario`, or no scenario when it is absent. */ +export function controlPlan(scenario: string | undefined): ReadonlyArray { + return [ + { method: "DELETE", path: "/_ctrl/requests" }, + { method: "DELETE", path: "/_ctrl/scenario" }, + ...(scenario === undefined + ? [] + : [{ method: "POST", path: "/_ctrl/scenario", body: { name: scenario } } as const]), + ]; +} + +const controlUrl = (apiUrl: string, path: string) => new URL(path, apiUrl).toString(); + +const failure = (step: string) => (cause: unknown) => + new ReplayControlError({ + step, + message: `${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + +const send = Effect.fn("ReplayControl.send")(function* ( + apiUrl: string, + method: "GET" | "DELETE" | "POST", + path: string, + body?: { readonly name: string }, +) { + const step = `${method} ${path}`; + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const base = + method === "GET" + ? HttpClientRequest.get(controlUrl(apiUrl, path)) + : method === "DELETE" + ? HttpClientRequest.delete(controlUrl(apiUrl, path)) + : HttpClientRequest.post(controlUrl(apiUrl, path)); + const request = body === undefined ? base : HttpClientRequest.bodyJsonUnsafe(base, body); + return yield* client.execute(request).pipe(Effect.mapError(failure(step))); +}); + +/** Resets the request log and scenario so the next launch sees a freshly loaded scenario. */ +export const resetReplay = Effect.fn("ReplayControl.reset")(function* ( + apiUrl: string, + scenario: string | undefined, +) { + for (const call of controlPlan(scenario)) { + yield* send(apiUrl, call.method, call.path, call.body); + } +}); + +const RequestLog = Schema.Array(Schema.Struct({ method: Schema.String, pathname: Schema.String })); + +/** Number of API requests the server has logged since the last reset. */ +export const readRequestLogCount = Effect.fn("ReplayControl.readRequestLogCount")(function* ( + apiUrl: string, +) { + const response = yield* send(apiUrl, "GET", "/_ctrl/requests"); + const json = yield* HttpClientResponse.schemaBodyJson(RequestLog)(response).pipe( + Effect.mapError(failure("GET /_ctrl/requests body")), + ); + return json.length; +}); diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts index faaaac4c2b..5b4a084672 100644 --- a/apps/cli/scripts/sweep-live-projects.integration.test.ts +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -1,10 +1,17 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); -const directories: string[] = []; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Config, + ConfigProvider, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + PlatformError, +} from "effect"; // Per-sweep hang guard: a spawned run gets this long before the safety net kills // it. A test's own timeout must cover every sweep it runs plus report margin, so @@ -13,9 +20,26 @@ const SWEEP_GUARD_MS = 30_000; const REPORT_MARGIN_MS = 10_000; const sweepBudget = (sweeps: number) => sweeps * SWEEP_GUARD_MS + REPORT_MARGIN_MS; -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); -}); +class SweepHarnessFailed extends Data.TaggedError("SweepHarnessFailed")<{ + readonly cause: unknown; +}> {} + +const sweepLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); + +const provideSweep = ( + sweep: Effect.Effect, +) => + sweep.pipe( + Effect.catchTags({ + PlatformError: (error) => Effect.die(error.cause ?? error), + SweepHarnessFailed: (error) => Effect.die(error.cause), + }), + Effect.provide(sweepLayer), + ); type Scenario = { lists: Array | ((deletes: ReadonlyArray) => unknown); @@ -23,84 +47,106 @@ type Scenario = { deleteStatuses?: Record>; }; -async function runSweep(scenario: Scenario) { - const directory = await mkdtemp(path.join(tmpdir(), "sweep-live-projects-")); - directories.push(directory); +const readText = (stream: ReadableStream) => + Effect.tryPromise({ + try: () => new Response(stream).text(), + catch: (cause) => new SweepHarnessFailed({ cause }), + }); + +const runSweep = Effect.fnUntraced(function* (scenario: Scenario) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "sweep-live-projects-" }); const fakeSleep = path.join(directory, "sleep"); - await writeFile(fakeSleep, "#!/bin/sh\nexit 0\n"); - await chmod(fakeSleep, 0o755); + yield* fs.writeFileString(fakeSleep, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(fakeSleep, 0o755); let listIndex = 0; const deletes: string[] = []; - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - const ref = url.pathname.split("/").pop() ?? ""; - if (request.method === "GET" && url.pathname === "/v1/projects") { - const value = - typeof scenario.lists === "function" - ? scenario.lists(deletes) - : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; - return typeof value === "number" - ? new Response("temporary failure", { status: value }) - : Response.json(value); - } - if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { - deletes.push(ref); - const statuses = scenario.deleteStatuses?.[ref]; - const result: { status: number; body?: unknown } = - statuses === undefined - ? (scenario.deletes[ref] ?? { status: 404 }) - : { - status: - statuses[ - Math.min( - deletes.filter((value) => value === ref).length - 1, - statuses.length - 1, - ) - ] ?? 500, - }; - return new Response(result.body === undefined ? null : JSON.stringify(result.body), { - status: result.status, - headers: { "x-request-id": `delete-${ref}` }, - }); - } - return new Response("not found", { status: 404 }); - }, - }); - try { - const child = Bun.spawn(["bash", script, "e2e-"], { - env: { - ...globalThis.process.env, - PATH: `${directory}:${globalThis.process.env.PATH}`, - SUPABASE_ACCESS_TOKEN: "test-token", - SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + const serve = () => + Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const ref = url.pathname.split("/").pop() ?? ""; + if (request.method === "GET" && url.pathname === "/v1/projects") { + const value = + typeof scenario.lists === "function" + ? scenario.lists(deletes) + : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; + return typeof value === "number" + ? new Response("temporary failure", { status: value }) + : Response.json(value); + } + if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { + deletes.push(ref); + const statuses = scenario.deleteStatuses?.[ref]; + const result: { status: number; body?: unknown } = + statuses === undefined + ? (scenario.deletes[ref] ?? { status: 404 }) + : { + status: + statuses[ + Math.min( + deletes.filter((value) => value === ref).length - 1, + statuses.length - 1, + ) + ] ?? 500, + }; + return new Response(result.body === undefined ? null : JSON.stringify(result.body), { + status: result.status, + headers: { "x-request-id": `delete-${ref}` }, + }); + } + return new Response("not found", { status: 404 }); }, - stdout: "pipe", - stderr: "pipe", + }); + return yield* Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.try({ try: serve, catch: (cause) => new SweepHarnessFailed({ cause }) }), + (server) => Effect.promise(() => server.stop(true)), + ); + const inheritedPath = yield* Config.option(Config.String("PATH")); + const child = yield* Effect.try({ + try: () => + Bun.spawn(["bash", script, "e2e-"], { + env: { + ...globalThis.process.env, + PATH: `${directory}:${Option.getOrUndefined(inheritedPath)}`, + SUPABASE_ACCESS_TOKEN: "test-token", + SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + }, + stdout: "pipe", + stderr: "pipe", + }), + catch: (cause) => new SweepHarnessFailed({ cause }), }); let hung = false; - const timeout = setTimeout(() => { - hung = true; - child.kill(); - }, SWEEP_GUARD_MS); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - clearTimeout(timeout); + const guard = yield* Effect.sleep(SWEEP_GUARD_MS).pipe( + Effect.andThen( + Effect.sync(() => { + hung = true; + child.kill(); + }), + ), + Effect.forkChild, + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [Effect.promise(() => child.exited), readText(child.stdout), readText(child.stderr)], + { concurrency: "unbounded" }, + ); + yield* Fiber.interrupt(guard); if (hung) { - throw new Error( - `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + return yield* Effect.die( + new Error( + `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + ), ); } return { exitCode, stdout, stderr, deletes }; - } finally { - await server.stop(true); - } -} + }).pipe(Effect.scoped); +}, provideSweep); const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); @@ -108,87 +154,101 @@ describe.skipIf(process.platform === "win32")( "sweep-live-projects.sh", { timeout: sweepBudget(1) }, () => { - test("accepts refused deletion when a fresh authenticated list shows absence", async () => { - const result = await runSweep({ - lists: [[active("gone")], []], - deletes: { gone: { status: 403, body: { message: "already removed" } } }, - }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["gone"]); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); - }); - - test("accepts a terminal status and converges after a stale list", async () => { - const result = await runSweep({ - lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], - deletes: { stale: { status: 202 } }, - }); - expect(result.exitCode).toBe(0); - }); + it.live("accepts refused deletion when a fresh authenticated list shows absence", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); + }), + ); - test( - "retries a transient read failure but rejects malformed evidence", - async () => { - const transient = await runSweep({ - lists: [503, [active("retry")], []], - deletes: { retry: { status: 202 } }, + it.live("accepts a terminal status and converges after a stale list", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [ + [active("stale")], + [active("stale")], + [{ ...active("stale"), status: "REMOVED" }], + ], + deletes: { stale: { status: 202 } }, }); - expect(transient.exitCode).toBe(0); + expect(result.exitCode).toBe(0); + }), + ); - const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); - expect(malformed.exitCode).not.toBe(0); - expect(malformed.deletes).toEqual([]); - }, + it.live( + "retries a transient read failure but rejects malformed evidence", + () => + Effect.gen(function* () { + const transient = yield* runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); + + const malformed = yield* runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }), sweepBudget(2), ); - test( + it.live( "fails terminal listing errors without retrying or deleting", - async () => { - const forbidden = await runSweep({ lists: [403, []], deletes: {} }); - expect(forbidden.exitCode).not.toBe(0); - expect(forbidden.deletes).toEqual([]); - - const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); - expect(unavailable.exitCode).not.toBe(0); - expect(unavailable.deletes).toEqual([]); - - const malformedReconciliation = await runSweep({ - lists: [[active("bad-evidence")], { projects: [] }], - deletes: { "bad-evidence": { status: 403 } }, - }); - expect(malformedReconciliation.exitCode).not.toBe(0); - }, + () => + Effect.gen(function* () { + const forbidden = yield* runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); + + const unavailable = yield* runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); + + const malformedReconciliation = yield* runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }), sweepBudget(3), ); - test("attempts every owned project and fails if one remains active", async () => { - const result = await runSweep({ - lists: [ - [ - active("stuck"), - active("other"), - { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + it.live("attempts every owned project and fails if one remains active", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [ + [ + active("stuck"), + active("other"), + { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + ], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], ], - [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - ], - deletes: { stuck: { status: 403 }, other: { status: 204 } }, - }); - expect(result.exitCode).not.toBe(0); - expect(result.deletes).toEqual(["stuck", "other"]); - }); + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); + }), + ); - test("retries a transient deletion after fresh evidence still shows the project", async () => { - const result = await runSweep({ - lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), - deletes: {}, - deleteStatuses: { flaky: [503, 204] }, - }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["flaky", "flaky"]); - expect(result.stderr).toContain( - "delete retry completed for project flaky (HTTP 204 request delete-flaky)", - ); - }); + it.live("retries a transient deletion after fresh evidence still shows the project", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); + }), + ); }, ); diff --git a/apps/cli/scripts/sync-versions.ts b/apps/cli/scripts/sync-versions.ts index c94429bc4c..e7d01434ac 100644 --- a/apps/cli/scripts/sync-versions.ts +++ b/apps/cli/scripts/sync-versions.ts @@ -1,6 +1,7 @@ import { parseArgs } from "node:util"; -import path from "node:path"; import process from "node:process"; +import { BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, Path, Schema } from "effect"; const PACKAGE_PATHS = { cli: ["apps", "cli"], @@ -22,22 +23,52 @@ const { values } = parseArgs({ }, }); +class PackageJsonFailed extends Data.TaggedError("PackageJsonFailed")<{ + readonly cause: unknown; +}> {} + +const encodePackageJson = Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: "\t" }), +); + const version = values.version; -if (!version) { - console.error("Usage: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); +const main = Effect.gen(function* () { + if (!version) { + yield* Console.error("Usage: bun apps/cli/scripts/sync-versions.ts --version "); + process.exit(1); + } + + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); -for (const pkg of ALL_PACKAGES) { - const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); - const pkgJson = await Bun.file(pkgJsonPath).json(); + for (const pkg of ALL_PACKAGES) { + const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); + const pkgJson = yield* Effect.tryPromise({ + try: () => Bun.file(pkgJsonPath).json(), + catch: (cause) => new PackageJsonFailed({ cause }), + }); - pkgJson.version = version; + pkgJson.version = version; + + const contents = `${yield* encodePackageJson(pkgJson)}\n`; + yield* Effect.tryPromise({ + try: () => Bun.write(pkgJsonPath, contents), + catch: (cause) => new PackageJsonFailed({ cause }), + }); + yield* Console.log(`Updated ${pkg} to v${version}`); + } + + yield* Console.log(`\nAll packages synced to v${version}.`); +}); - await Bun.write(pkgJsonPath, `${JSON.stringify(pkgJson, null, "\t")}\n`); - console.log(`Updated ${pkg} to v${version}`); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } -console.log(`\nAll packages synced to v${version}.`); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(BunPath.layer), + ), +); diff --git a/apps/cli/scripts/trace-report.ts b/apps/cli/scripts/trace-report.ts index 29cb80c39d..41cc789ece 100644 --- a/apps/cli/scripts/trace-report.ts +++ b/apps/cli/scripts/trace-report.ts @@ -8,13 +8,23 @@ import { Console, Effect, FileSystem, Schema, Stdio } from "effect"; const KeyValues = Schema.Array( Schema.Struct({ key: Schema.String, - value: Schema.Struct({ stringValue: Schema.optional(Schema.String) }), + value: Schema.Struct({ + stringValue: Schema.optional(Schema.String), + // OTLP/JSON encodes 64-bit integers as strings; this exporter writes numbers. + intValue: Schema.optional(Schema.Union([Schema.Finite, Schema.String])), + }), }), ); const stringAttribute = (attributes: typeof KeyValues.Type | undefined, key: string) => attributes?.find((attribute) => attribute.key === key)?.value.stringValue; +const intAttribute = (attributes: typeof KeyValues.Type | undefined, key: string) => { + const value = attributes?.find((attribute) => attribute.key === key)?.value.intValue; + const number = value === undefined ? Number.NaN : Number(value); + return Number.isInteger(number) ? number : undefined; +}; + /** Labels container CLI spans with their verb so `pull` and `run` report separately. */ export function reportSpanName(name: string, subcommand: string | undefined): string { return subcommand === undefined ? name : `${name} (${subcommand})`; @@ -27,7 +37,8 @@ const OtlpSpan = Schema.Struct({ name: Schema.String, startTimeUnixNano: Schema.String, endTimeUnixNano: Schema.String, - status: Schema.Struct({ code: Schema.Number }), + /* Any JSON number, including `1e309` (parses to `Infinity`), which `Schema.Finite` rejects. */ + status: Schema.Struct({ code: Schema.declare((u): u is number => typeof u === "number") }), attributes: Schema.optional(KeyValues), events: Schema.optional( Schema.Array(Schema.Struct({ name: Schema.String, attributes: KeyValues })), @@ -44,6 +55,17 @@ const TraceBatch = Schema.fromJsonString( }), ); +const ReportJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +/** Attributes of an HTTP client span; `path` has no query string. */ +export interface HttpSpanAttributes { + readonly method: string; + readonly path: string | undefined; + readonly address: string | undefined; + readonly port: number | undefined; + readonly status: number | undefined; +} + export interface ReportSpan { readonly traceId: string; readonly spanId: string; @@ -53,6 +75,8 @@ export interface ReportSpan { readonly endMs: number; readonly failed: boolean; readonly errorType: string | undefined; + /** Present only on HTTP client spans. */ + readonly http?: HttpSpanAttributes; } export interface TraceReport { @@ -71,6 +95,19 @@ export interface TraceReport { } const STATUS_ERROR = 2; + +function httpAttributes(attributes: typeof KeyValues.Type | undefined) { + const method = stringAttribute(attributes, "http.request.method"); + if (method === undefined) return undefined; + return { + method, + path: stringAttribute(attributes, "url.path")?.split("?")[0], + address: stringAttribute(attributes, "server.address"), + port: intAttribute(attributes, "server.port"), + status: intAttribute(attributes, "http.response.status_code"), + } satisfies HttpSpanAttributes; +} + const nanosToMs = (value: string) => Number(BigInt(value) / 1_000n) / 1_000; const round = (value: number) => Math.round(value * 100) / 100; @@ -180,7 +217,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { let skippedLines = 0; for (const line of text.split("\n")) { if (line.trim().length === 0) continue; - const decoded = yield* Schema.decodeUnknownEffect(TraceBatch)(line).pipe(Effect.option); + const decoded = yield* Schema.decodeEffect(TraceBatch)(line).pipe(Effect.option); if (decoded._tag === "None") { skippedLines += 1; continue; @@ -189,6 +226,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { for (const resource of batch.resourceSpans) { for (const scope of resource.scopeSpans) { for (const span of scope.spans) { + const http = httpAttributes(span.attributes); spans.push({ traceId: span.traceId, spanId: span.spanId, @@ -201,6 +239,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { span.events?.find((event) => event.name === "exception")?.attributes, "exception.type", ), + ...(http === undefined ? {} : { http }), }); } } @@ -302,7 +341,11 @@ const main = Effect.gen(function* () { const header = `trace: ${trace.traceId}${trace.traceCount > 1 ? ` (latest of ${trace.traceCount}; choose one with --trace-id)` : ""}`; yield* Console.log( options.json - ? JSON.stringify({ traceId: trace.traceId, traceCount: trace.traceCount, ...report }, null, 2) + ? yield* Schema.encodeEffect(ReportJson)({ + traceId: trace.traceId, + traceCount: trace.traceCount, + ...report, + }) : `${header}\n${formatReport(report)}`, ); }); diff --git a/apps/cli/scripts/trace-report.unit.test.ts b/apps/cli/scripts/trace-report.unit.test.ts index 66e7bb5140..b9195f141f 100644 --- a/apps/cli/scripts/trace-report.unit.test.ts +++ b/apps/cli/scripts/trace-report.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { analyzeTrace, parseTraceReportArgs, @@ -115,7 +115,7 @@ describe("reportSpanName", () => { }); describe("readSpans", () => { - it("keeps decodable batches and counts lines that do not decode", async () => { + it.effect("keeps decodable batches and counts lines that do not decode", () => { const batch = JSON.stringify({ resourceSpans: [ { @@ -137,19 +137,83 @@ describe("readSpans", () => { ], }); - const result = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); - yield* fs.writeFileString(file, `${batch}\n{"resourceSpans": [\n`); - return yield* readSpans(file); - }), - ).pipe(Effect.provide(BunServices.layer)), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); + yield* fs.writeFileString(file, `${batch}\n{"resourceSpans": [\n`); + const result = yield* readSpans(file); + + expect(result.spans.map((span) => span.name)).toEqual(["cli.run"]); + expect(result.skippedLines).toBe(1); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); + +describe("readSpans HTTP attributes", () => { + const fixture = new URL("./__fixtures__/api-call-trace.jsonl", import.meta.url).pathname; + + it.effect("reads method, path, host, port and status from traces of both CLI generations", () => + Effect.gen(function* () { + const { spans, skippedLines } = yield* readSpans(fixture); + const http = spans.flatMap((span) => (span.http === undefined ? [] : [span.http])); + + expect(skippedLines).toBe(0); + expect(http).toHaveLength(2); + for (const call of http) { + expect(call).toEqual({ + method: "GET", + path: "/v1/projects", + address: "127.0.0.1", + port: 62824, + status: 200, + }); + } + expect(spans.find((span) => span.name === "cli.run")?.http).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("accepts OTLP string-encoded integers and drops the query string from the path", () => { + const batch = JSON.stringify({ + resourceSpans: [ + { + scopeSpans: [ + { + spans: [ + { + traceId: "t", + spanId: "s", + name: "POST", + startTimeUnixNano: "0", + endTimeUnixNano: "1000000", + status: { code: 1 }, + attributes: [ + { key: "http.request.method", value: { stringValue: "POST" } }, + { key: "url.path", value: { stringValue: "/v1/x?limit=1" } }, + { key: "server.port", value: { intValue: "8443" } }, + { key: "http.response.status_code", value: { intValue: "503" } }, + ], + }, + ], + }, + ], + }, + ], + }); - expect(result.spans.map((span) => span.name)).toEqual(["cli.run"]); - expect(result.skippedLines).toBe(1); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); + yield* fs.writeFileString(file, `${batch}\n`); + const { spans } = yield* readSpans(file); + + expect(spans[0]?.http).toEqual({ + method: "POST", + path: "/v1/x", + address: undefined, + port: 8443, + status: 503, + }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/scripts/update-homebrew.ts b/apps/cli/scripts/update-homebrew.ts index 942cf61a0d..14744fe66b 100644 --- a/apps/cli/scripts/update-homebrew.ts +++ b/apps/cli/scripts/update-homebrew.ts @@ -1,9 +1,8 @@ import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import { homebrewClassName, homebrewConflictsLine } from "./release-channels.ts"; const { values } = parseArgs({ @@ -17,57 +16,63 @@ const { values } = parseArgs({ }, }); -const version = values.version; -if (!version) { - console.error( - "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]", - ); - process.exit(1); -} +class ShellFailed extends Data.TaggedError("ShellFailed")<{ readonly cause: unknown }> {} -const repo = values.repo!; -const tap = values.tap!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -// The class and filename vary by channel (e.g. "supabase-beta" -> "SupabaseBeta", "supabase@2" -> -// "SupabaseAT2"), but the installed binary is always `supabase`. -const className = homebrewClassName(name); - -// The Go sidecar is looked up by exact filename next to the running binary, so it must install -// under its original name; `if File.exist?` keeps the formula working when a build ships only -// the CLI binary. -const installBlock = [ - ` bin.install "supabase"`, - ` bin.install "supabase-go" if File.exist?("supabase-go")`, -].join("\n"); - -const conflictsBlock = homebrewConflictsLine(name); -const conflictsSection = conflictsBlock ? `${conflictsBlock}\n\n` : ""; - -const testInvocation = `#{bin}/supabase`; - -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} +class FormulaError extends Data.TaggedError("FormulaError")<{ readonly message: string }> {} -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} +const shell = (command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellFailed({ cause }) }); + +const main = Effect.gen(function* () { + const version = values.version; + if (!version) { + yield* Console.error( + "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = values.repo!; + const tap = values.tap!; + const name = values.name!; + const local = values.local!; + const dryRun = values["dry-run"]!; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + + // The class and filename vary by channel (e.g. "supabase-beta" -> "SupabaseBeta", "supabase@2" -> + // "SupabaseAT2"), but the installed binary is always `supabase`. + const className = homebrewClassName(name); + + const installBlock = [` bin.install "supabase"`].join("\n"); + + const conflictsBlock = homebrewConflictsLine(name); + const conflictsSection = conflictsBlock ? `${conflictsBlock}\n\n` : ""; + + const testInvocation = `#{bin}/supabase`; -const baseUrl = local - ? `file://${distDir}` - : `https://github.com/${repo}/releases/download/v${version}`; + const checksums = new Map(); + const checksumsText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(path.join(distDir, "checksums.txt")), + ); + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/) as [string, string]; + checksums.set(file, hash); + } + + const sha = Effect.fnUntraced(function* (file: string) { + const hash = checksums.get(file); + if (!hash) return yield* new FormulaError({ message: `Checksum not found for ${file}` }); + return hash; + }); -const formula = `class ${className} < Formula + const baseUrl = local + ? `file://${distDir}` + : `https://github.com/${repo}/releases/download/v${version}`; + + const formula = `class ${className} < Formula desc "Supabase CLI" homepage "https://supabase.com" version "${version}" @@ -76,20 +81,20 @@ const formula = `class ${className} < Formula on_macos do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_darwin_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_arm64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_darwin_arm64.tar.gz`)}" else url "${baseUrl}/supabase_${version}_darwin_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_amd64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_darwin_amd64.tar.gz`)}" end end on_linux do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_linux_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_arm64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_linux_arm64.tar.gz`)}" else url "${baseUrl}/supabase_${version}_linux_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_amd64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_linux_amd64.tar.gz`)}" end end @@ -103,43 +108,63 @@ ${installBlock} end `; -const formulaFileName = `${name}.rb`; -const formulaOut = path.join(distDir, formulaFileName); -await writeFile(formulaOut, formula); -console.log(`Formula written to ${formulaOut}`); + const formulaFileName = `${name}.rb`; + const formulaOut = path.join(distDir, formulaFileName); + yield* fs.writeFileString(formulaOut, formula); + yield* Console.log(`Formula written to ${formulaOut}`); -if (local || dryRun) { - console.log(formula); - process.exit(0); -} + if (local || dryRun) { + yield* Console.log(formula); + return yield* Effect.sync(() => process.exit(0)); + } -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + const hasStagedChanges = Effect.fnUntraced(function* (repoDir: string, repoPath: string) { + const diff = yield* shell(() => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ); + if (diff.exitCode === 0) return false; + if (diff.exitCode === 1) return true; + return yield* new FormulaError({ message: `Failed to inspect staged changes for ${repoPath}` }); + }); + + const publishToTap = (tmpDir: string) => + Effect.gen(function* () { + const tapUrl = `https://github.com/${tap}.git`; + yield* shell(() => $`git clone ${tapUrl} ${tmpDir}`); + + const formulaDir = path.join(tmpDir, "Formula"); + yield* shell(() => $`mkdir -p ${formulaDir}`); + const tapFormulaPath = path.join(formulaDir, formulaFileName); + const tapFormulaRepoPath = `Formula/${formulaFileName}`; + yield* fs.writeFileString(tapFormulaPath, formula); + + yield* shell(() => $`git -C ${tmpDir} add ${tapFormulaRepoPath}`); + if (yield* hasStagedChanges(tmpDir, tapFormulaRepoPath)) { + yield* shell(() => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell(() => $`git -C ${tmpDir} push`); + yield* Console.log(`Pushed formula update to ${tap}`); + } else { + yield* Console.log(`Formula ${formulaFileName} is already up to date in ${tap}`); + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "homebrew-tap-" }); + const outcome = yield* Effect.exit(restore(publishToTap(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true }); + return yield* outcome; + }), + ); +}); -const tmpDir = await mkdtemp(path.join(tmpdir(), "homebrew-tap-")); -try { - const tapUrl = `https://github.com/${tap}.git`; - await $`git clone ${tapUrl} ${tmpDir}`; - - const formulaDir = path.join(tmpDir, "Formula"); - await $`mkdir -p ${formulaDir}`; - const tapFormulaPath = path.join(formulaDir, formulaFileName); - const tapFormulaRepoPath = `Formula/${formulaFileName}`; - await writeFile(tapFormulaPath, formula); - - await $`git -C ${tmpDir} add ${tapFormulaRepoPath}`; - if (await hasStagedChanges(tmpDir, tapFormulaRepoPath)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed formula update to ${tap}`); - } else { - console.log(`Formula ${formulaFileName} is already up to date in ${tap}`); - } -} finally { - await rm(tmpDir, { recursive: true }); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } + +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index 596f6405f7..cadbd2bdb0 100644 --- a/apps/cli/scripts/update-scoop.ts +++ b/apps/cli/scripts/update-scoop.ts @@ -1,9 +1,8 @@ import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; const { values } = parseArgs({ options: { @@ -16,122 +15,158 @@ const { values } = parseArgs({ }, }); -const version = values.version; -if (!version) { - console.error( - "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]", +class ShellFailed extends Data.TaggedError("ShellFailed")<{ readonly cause: unknown }> {} + +class ManifestError extends Data.TaggedError("ManifestError")<{ readonly message: string }> {} + +const shell = (command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellFailed({ cause }) }); + +const encodeManifest = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 4 })); + +const main = Effect.gen(function* () { + const version = values.version; + if (!version) { + yield* Console.error( + "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = values.repo!; + const bucket = values.bucket!; + const name = values.name!; + const local = values.local!; + const dryRun = values["dry-run"]!; + + // The shipped binary is always `supabase.exe`; only the manifest filename differs (e.g. + // `supabase-beta.json`), so stable and beta can coexist in the same bucket. + const binEntry = "supabase.exe"; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + + const checksums = new Map(); + const checksumsText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(path.join(distDir, "checksums.txt")), ); - process.exit(1); -} + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/) as [string, string]; + checksums.set(file, hash); + } -const repo = values.repo!; -const bucket = values.bucket!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; - -// The shipped binary is always `supabase.exe`; only the manifest filename differs (e.g. -// `supabase-beta.json`), so stable and beta can coexist in the same bucket. -const binEntry = "supabase.exe"; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} + const sha = Effect.fnUntraced(function* (file: string) { + const hash = checksums.get(file); + if (!hash) return yield* new ManifestError({ message: `Checksum not found for ${file}` }); + return hash; + }); -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} + // Scoop supports file:// URLs for local testing + const baseUrl = local + ? `file:///${distDir.replace(/\\/g, "/")}` + : `https://github.com/${repo}/releases/download/v${version}`; -// Scoop supports file:// URLs for local testing -const baseUrl = local - ? `file:///${distDir.replace(/\\/g, "/")}` - : `https://github.com/${repo}/releases/download/v${version}`; - -// Published releases use unversioned Windows tarball names (release-shared.yml copies the -// versioned build); local builds only emit versioned archives, so --local keeps those names. -const amd64Tar = local - ? `supabase_${version}_windows_amd64.tar.gz` - : "supabase_windows_amd64.tar.gz"; -const arm64Tar = local - ? `supabase_${version}_windows_arm64.tar.gz` - : "supabase_windows_arm64.tar.gz"; - -const manifest = { - version, - description: "Supabase CLI", - homepage: "https://supabase.com/", - license: "MIT", - architecture: { - "64bit": { - url: `${baseUrl}/${amd64Tar}`, - hash: sha(`supabase_${version}_windows_amd64.tar.gz`), - }, - arm64: { - url: `${baseUrl}/${arm64Tar}`, - hash: sha(`supabase_${version}_windows_arm64.tar.gz`), - }, - }, - bin: binEntry, - checkver: { - github: `https://github.com/${repo}`, - }, - autoupdate: { + // Published releases use unversioned Windows tarball names (release-shared.yml copies the + // versioned build); local builds only emit versioned archives, so --local keeps those names. + const amd64Tar = local + ? `supabase_${version}_windows_amd64.tar.gz` + : "supabase_windows_amd64.tar.gz"; + const arm64Tar = local + ? `supabase_${version}_windows_arm64.tar.gz` + : "supabase_windows_arm64.tar.gz"; + + const manifest = { + version, + description: "Supabase CLI", + homepage: "https://supabase.com/", + license: "MIT", architecture: { "64bit": { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + url: `${baseUrl}/${amd64Tar}`, + hash: yield* sha(`supabase_${version}_windows_amd64.tar.gz`), }, arm64: { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + url: `${baseUrl}/${arm64Tar}`, + hash: yield* sha(`supabase_${version}_windows_arm64.tar.gz`), }, }, - hash: { - url: "$baseurl/supabase_$version_checksums.txt", + bin: binEntry, + checkver: { + github: `https://github.com/${repo}`, }, - }, -}; + autoupdate: { + architecture: { + "64bit": { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + }, + arm64: { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + }, + }, + hash: { + url: "$baseurl/supabase_$version_checksums.txt", + }, + }, + }; -const manifestFileName = `${name}.json`; -const manifestJson = `${JSON.stringify(manifest, null, 4)}\n`; -const manifestOut = path.join(distDir, manifestFileName); -await writeFile(manifestOut, manifestJson); -console.log(`Manifest written to ${manifestOut}`); + const manifestFileName = `${name}.json`; + const manifestJson = `${yield* encodeManifest(manifest)}\n`; + const manifestOut = path.join(distDir, manifestFileName); + yield* fs.writeFileString(manifestOut, manifestJson); + yield* Console.log(`Manifest written to ${manifestOut}`); -if (local || dryRun) { - console.log(manifestJson); - process.exit(0); -} + if (local || dryRun) { + yield* Console.log(manifestJson); + return yield* Effect.sync(() => process.exit(0)); + } -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + const hasStagedChanges = Effect.fnUntraced(function* (repoDir: string, repoPath: string) { + const diff = yield* shell(() => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ); + if (diff.exitCode === 0) return false; + if (diff.exitCode === 1) return true; + return yield* new ManifestError({ + message: `Failed to inspect staged changes for ${repoPath}`, + }); + }); -const tmpDir = await mkdtemp(path.join(tmpdir(), "scoop-bucket-")); -try { - const bucketUrl = `https://github.com/${bucket}.git`; - await $`git clone ${bucketUrl} ${tmpDir}`; - - const bucketManifestPath = path.join(tmpDir, manifestFileName); - await writeFile(bucketManifestPath, manifestJson); - - await $`git -C ${tmpDir} add ${manifestFileName}`; - if (await hasStagedChanges(tmpDir, manifestFileName)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed manifest update to ${bucket}`); - } else { - console.log(`Manifest ${manifestFileName} is already up to date in ${bucket}`); - } -} finally { - await rm(tmpDir, { recursive: true }); + const publishToBucket = (tmpDir: string) => + Effect.gen(function* () { + const bucketUrl = `https://github.com/${bucket}.git`; + yield* shell(() => $`git clone ${bucketUrl} ${tmpDir}`); + + const bucketManifestPath = path.join(tmpDir, manifestFileName); + yield* fs.writeFileString(bucketManifestPath, manifestJson); + + yield* shell(() => $`git -C ${tmpDir} add ${manifestFileName}`); + if (yield* hasStagedChanges(tmpDir, manifestFileName)) { + yield* shell(() => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell(() => $`git -C ${tmpDir} push`); + yield* Console.log(`Pushed manifest update to ${bucket}`); + } else { + yield* Console.log(`Manifest ${manifestFileName} is already up to date in ${bucket}`); + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "scoop-bucket-" }); + const outcome = yield* Effect.exit(restore(publishToBucket(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true }); + return yield* outcome; + }), + ); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } + +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/upload-release-assets.integration.test.ts b/apps/cli/scripts/upload-release-assets.integration.test.ts index 699cae4bf1..879dd8986a 100644 --- a/apps/cli/scripts/upload-release-assets.integration.test.ts +++ b/apps/cli/scripts/upload-release-assets.integration.test.ts @@ -1,7 +1,16 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Clock, + Config, + ConfigProvider, + Effect, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { fileURLToPath } from "node:url"; import { createSpawnRun, @@ -17,15 +26,13 @@ const asset = { path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", name: "supabase_1.0.0_darwin_arm64.tar.gz", }; -const temporaryDirectories: string[] = []; +const platformLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); // A stand-in `gh` that logs every call, fails or hangs once per marker file, and serves // `release view` from view.json. @@ -54,18 +61,21 @@ fi exit 0 `; -async function fakeGhOnPath() { - const directory = await mkdtemp(path.join(tmpdir(), "upload-release-assets-")); - temporaryDirectories.push(directory); +const fakeGhOnPath = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "upload-release-assets-" }); const bin = path.join(directory, "bin"); - await mkdir(bin); - await writeFile(path.join(bin, "gh"), fakeGh); - await chmod(path.join(bin, "gh"), 0o755); - const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${process.env.PATH}` }; - const calls = async () => - (await readFile(path.join(directory, "calls.log"), "utf8")).trimEnd().split("\n"); + yield* fs.makeDirectory(bin); + yield* fs.writeFileString(path.join(bin, "gh"), fakeGh); + yield* fs.chmod(path.join(bin, "gh"), 0o755); + const inheritedPath = Option.getOrUndefined(yield* Config.option(Config.String("PATH"))); + const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${inheritedPath}` }; + const calls = fs + .readFileString(path.join(directory, "calls.log")) + .pipe(Effect.map((text) => text.trimEnd().split("\n"))); return { directory, env, calls }; -} +}); function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { const logs: string[] = []; @@ -73,120 +83,153 @@ function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { logs, io: { run, - fileExists: async () => true, - sleep: () => Promise.resolve(), - log: (line) => { - logs.push(line); - }, + fileExists: () => Effect.succeed(true), + sleep: () => Effect.void, + log: (line) => + Effect.sync(() => { + logs.push(line); + }), }, }; } describe("upload-release-assets against a fake gh", () => { - test("kills an upload that outlives the timeout and succeeds on the retry", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `hang-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - const startedAt = Date.now(); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - const elapsed = Date.now() - startedAt; - expect(elapsed).toBeGreaterThanOrEqual(5_000); - expect(elapsed).toBeLessThan(25_000); - expect(await calls()).toEqual([ - `release upload v1.0.0 ${asset.path} --clobber`, - `release upload v1.0.0 ${asset.path} --clobber`, - ]); - expect(logs).toEqual([ - `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, - `Uploaded ${asset.name} (attempt 2).`, - ]); - }, 40_000); - - test("kills an upload that ignores SIGTERM once the grace period passes", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `ignore-term-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - const startedAt = Date.now(); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - const elapsed = Date.now() - startedAt; - expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); - expect(elapsed).toBeLessThan(25_000); - expect(await calls()).toHaveLength(2); - expect(logs[0]).toBe( - `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, - ); - }, 40_000); - - test("surfaces gh's stderr for a failed attempt and retries it", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `fail-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - expect(await calls()).toHaveLength(2); - expect(logs[0]).toBe( - `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, - ); - }); - - test("reads uploaded asset names from gh release view", async () => { - const { directory, env } = await fakeGhOnPath(); - await writeFile( - path.join(directory, "view.json"), - JSON.stringify({ - assets: [ - { name: "install", state: "uploaded" }, - { name: "checksums.txt", state: "starter" }, - ], - }), - ); - const { io } = recordingIo(createSpawnRun(env)); - - await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["install"]); - }); - - test("runs as a command and uploads all 22 assets from the working directory", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - const workdir = path.join(directory, "work"); - await mkdir(path.join(workdir, "dist"), { recursive: true }); - for (const asset of releaseAssets("1.0.0")) { - await writeFile(path.join(workdir, asset.path), asset.name); - } - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) throw new Error("Bun executable not found"); - - const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { - cwd: workdir, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - - expect(exitCode, stderr).toBe(0); - expect(await calls()).toHaveLength(22); - expect(stdout.trim().split("\n")).toHaveLength(22); - expect(stdout).toContain("Uploaded install (attempt 1)."); - }, 20_000); + it.live( + "kills an upload that outlives the timeout and succeeds on the retry", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `hang-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = yield* Clock.currentTimeMillis; + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000); + expect(elapsed).toBeLessThan(25_000); + expect(yield* calls).toEqual([ + `release upload v1.0.0 ${asset.path} --clobber`, + `release upload v1.0.0 ${asset.path} --clobber`, + ]); + expect(logs).toEqual([ + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + `Uploaded ${asset.name} (attempt 2).`, + ]); + }).pipe(Effect.provide(platformLayer)), + 40_000, + ); + + it.live( + "kills an upload that ignores SIGTERM once the grace period passes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `ignore-term-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = yield* Clock.currentTimeMillis; + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); + expect(elapsed).toBeLessThan(25_000); + expect(yield* calls).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + ); + }).pipe(Effect.provide(platformLayer)), + 40_000, + ); + + it.live("surfaces gh's stderr for a failed attempt and retries it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `fail-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + expect(yield* calls).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, + ); + }).pipe(Effect.provide(platformLayer)), + ); + + it.live("reads uploaded asset names from gh release view", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env } = yield* fakeGhOnPath; + yield* fs.writeFileString( + path.join(directory, "view.json"), + yield* encodeJson({ + assets: [ + { name: "install", state: "uploaded" }, + { name: "checksums.txt", state: "starter" }, + ], + }), + ); + const { io } = recordingIo(createSpawnRun(env)); + + expect(yield* uploadedAssetNames("v1.0.0", io)).toEqual(["install"]); + }).pipe(Effect.provide(platformLayer)), + ); + + it.live( + "runs as a command and uploads all 22 assets from the working directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + const workdir = path.join(directory, "work"); + yield* fs.makeDirectory(path.join(workdir, "dist"), { recursive: true }); + for (const asset of releaseAssets("1.0.0")) { + yield* fs.writeFileString(path.join(workdir, asset.path), asset.name); + } + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + + const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { + cwd: workdir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + + expect(exitCode, stderr).toBe(0); + expect(yield* calls).toHaveLength(22); + expect(stdout.trim().split("\n")).toHaveLength(22); + expect(stdout).toContain("Uploaded install (attempt 1)."); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); }); diff --git a/apps/cli/scripts/upload-release-assets.ts b/apps/cli/scripts/upload-release-assets.ts index d0edef1a35..c9b16b98a4 100644 --- a/apps/cli/scripts/upload-release-assets.ts +++ b/apps/cli/scripts/upload-release-assets.ts @@ -1,5 +1,6 @@ import { parseArgs } from "node:util"; import process from "node:process"; +import { Console, Data, Effect, Fiber } from "effect"; export interface ReleaseAsset { path: string; @@ -42,11 +43,25 @@ export interface RunResult { timedOut: boolean; } +class ReleaseAssetsError extends Data.TaggedError("ReleaseAssetsError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const toReleaseAssetsError = (cause: unknown) => + new ReleaseAssetsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + export interface ReleaseIo { - run: (argv: string[], options: { timeoutMs: number }) => Promise; - fileExists: (path: string) => Promise; - sleep: (ms: number) => Promise; - log: (line: string) => void; + run: ( + argv: string[], + options: { timeoutMs: number }, + ) => Effect.Effect; + fileExists: (path: string) => Effect.Effect; + sleep: (ms: number) => Effect.Effect; + log: (line: string) => Effect.Effect; } export interface RetryPolicy { @@ -71,38 +86,40 @@ function describeFailure(result: RunResult, timeoutMs: number): string { } /** Uploads each asset in turn, retrying per asset, and throws on the first asset that never lands. */ -export async function uploadAssets( +export const uploadAssets = Effect.fnUntraced(function* ( tag: string, assets: ReleaseAsset[], io: ReleaseIo, policy: RetryPolicy = defaultRetryPolicy, -): Promise { +) { for (const asset of assets) { - if (!(await io.fileExists(asset.path))) { - throw new Error(`Release asset ${asset.path} does not exist.`); + if (!(yield* io.fileExists(asset.path))) { + return yield* new ReleaseAssetsError({ + message: `Release asset ${asset.path} does not exist.`, + }); } for (let attempt = 1; ; attempt++) { - const result = await io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { + const result = yield* io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { timeoutMs: policy.timeoutMs, }); if (result.exitCode === 0) { - io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); + yield* io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); break; } const failure = describeFailure(result, policy.timeoutMs); if (attempt >= policy.maxAttempts) { - throw new Error( - `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, - ); + return yield* new ReleaseAssetsError({ + message: `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, + }); } const delayMs = policy.backoffMs(attempt); - io.log( + yield* io.log( `Upload of ${asset.name} failed on attempt ${attempt} (${failure}); retrying in ${delayMs / 1000}s.`, ); - await io.sleep(delayMs); + yield* io.sleep(delayMs); } } -} +}); interface ReleaseAssetView { name: string; @@ -110,17 +127,22 @@ interface ReleaseAssetView { } /** Names of the release's assets that GitHub reports as fully uploaded. */ -export async function uploadedAssetNames(tag: string, io: ReleaseIo): Promise { - const result = await io.run(["gh", "release", "view", tag, "--json", "assets"], { +export const uploadedAssetNames = Effect.fnUntraced(function* (tag: string, io: ReleaseIo) { + const result = yield* io.run(["gh", "release", "view", tag, "--json", "assets"], { timeoutMs: 60_000, }); if (result.exitCode !== 0) { - throw new Error(`Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`); + return yield* new ReleaseAssetsError({ + message: `Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`, + }); } - return parseAssetView(result.stdout, tag) + return (yield* Effect.try({ + try: () => parseAssetView(result.stdout, tag), + catch: toReleaseAssetsError, + })) .filter((asset) => asset.state === "uploaded") .map((asset) => asset.name); -} +}); function isAssetView(value: unknown): value is ReleaseAssetView { return ( @@ -153,42 +175,46 @@ export function missingAssets(expected: string[], uploaded: string[]): string[] return expected.filter((name) => !present.has(name)).sort(); } -const usage = `Usage: pnpm exec bun apps/cli/scripts/upload-release-assets.ts --version +const usage = `Usage: bun apps/cli/scripts/upload-release-assets.ts --version upload Upload every release asset to the draft release v, one at a time with retries. verify Fail unless every expected asset is present on v and reported as uploaded.`; -export async function main(argv: string[], io: ReleaseIo): Promise { - const { values, positionals } = parseArgs({ - args: argv, - options: { version: { type: "string" } }, - allowPositionals: true, +export const main = Effect.fnUntraced(function* (argv: string[], io: ReleaseIo) { + const { values, positionals } = yield* Effect.try({ + try: () => + parseArgs({ + args: argv, + options: { version: { type: "string" } }, + allowPositionals: true, + }), + catch: toReleaseAssetsError, }); const [command] = positionals; if (!values.version || (command !== "upload" && command !== "verify")) { - io.log(usage); + yield* io.log(usage); return 2; } const tag = `v${values.version}`; const assets = releaseAssets(values.version); if (command === "upload") { - await uploadAssets(tag, assets, io); + yield* uploadAssets(tag, assets, io); return 0; } const missing = missingAssets( assets.map((asset) => asset.name), - await uploadedAssetNames(tag, io), + yield* uploadedAssetNames(tag, io), ); if (missing.length > 0) { - io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); - for (const name of missing) io.log(` ${name}`); + yield* io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); + for (const name of missing) yield* io.log(` ${name}`); return 1; } - io.log(`All ${assets.length} expected assets are present on ${tag}.`); + yield* io.log(`All ${assets.length} expected assets are present on ${tag}.`); return 0; -} +}); /** * Time a timed-out command gets to exit on SIGTERM before it is killed outright, and the bound on @@ -196,56 +222,67 @@ export async function main(argv: string[], io: ReleaseIo): Promise { */ export const KILL_GRACE_MS = 5_000; -function within(promise: Promise, ms: number, fallback: T): Promise { - let timer: ReturnType | undefined; - const expiry = new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), ms); - }); - return Promise.race([promise, expiry]).finally(() => clearTimeout(timer)); -} +const within = (fiber: Fiber.Fiber, ms: number, fallback: string) => + Fiber.join(fiber).pipe( + Effect.timeoutOrElse({ duration: ms, orElse: () => Effect.succeed(fallback) }), + ); /** Runs a command with the given environment and terminates it once the timeout elapses. */ export function createSpawnRun( env: Record = process.env, ): ReleaseIo["run"] { - return async (argv, { timeoutMs }) => { - const child = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }); + return Effect.fnUntraced(function* (argv: string[], { timeoutMs }: { timeoutMs: number }) { + const child = yield* Effect.try({ + try: () => Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }), + catch: toReleaseAssetsError, + }); let timedOut = false; - let killTimer: ReturnType | undefined; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - killTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS); - }, timeoutMs); + yield* Effect.sleep(timeoutMs).pipe( + Effect.andThen( + Effect.sync(() => { + timedOut = true; + child.kill("SIGTERM"); + }), + ), + Effect.andThen(Effect.sleep(KILL_GRACE_MS)), + Effect.andThen(Effect.sync(() => child.kill("SIGKILL"))), + Effect.forkScoped({ startImmediately: true }), + ); // Start draining before the child exits so a chatty child never blocks on a full pipe. - const stdoutText = new Response(child.stdout).text(); - const stderrText = new Response(child.stderr).text(); - try { - const exitCode = await child.exited; - const [stdout, stderr] = await Promise.all([ - within(stdoutText, KILL_GRACE_MS, ""), - within(stderrText, KILL_GRACE_MS, ""), - ]); - return { exitCode, stdout, stderr, timedOut }; - } finally { - clearTimeout(timer); - clearTimeout(killTimer); - } - }; + const stdoutText = yield* Effect.tryPromise({ + try: () => new Response(child.stdout).text(), + catch: toReleaseAssetsError, + }).pipe(Effect.forkScoped({ startImmediately: true })); + const stderrText = yield* Effect.tryPromise({ + try: () => new Response(child.stderr).text(), + catch: toReleaseAssetsError, + }).pipe(Effect.forkScoped({ startImmediately: true })); + const exitCode = yield* Effect.tryPromise({ + try: () => child.exited, + catch: toReleaseAssetsError, + }); + const [stdout, stderr] = yield* Effect.all( + [within(stdoutText, KILL_GRACE_MS, ""), within(stderrText, KILL_GRACE_MS, "")], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr, timedOut }; + }, Effect.scoped); } export const processIo: ReleaseIo = { run: createSpawnRun(), - fileExists: (path) => Bun.file(path).exists(), - sleep: (ms) => Bun.sleep(ms), - log: (line) => console.log(line), + fileExists: (path) => + Effect.tryPromise({ try: () => Bun.file(path).exists(), catch: toReleaseAssetsError }), + sleep: (ms) => Effect.sleep(ms), + log: (line) => Console.log(line), }; if (import.meta.main) { - try { - process.exit(await main(process.argv.slice(2), processIo)); - } catch (cause) { - console.error(`::error::${cause instanceof Error ? cause.message : String(cause)}`); - process.exit(1); - } + process.exit( + await Effect.runPromise( + main(process.argv.slice(2), processIo).pipe( + Effect.catch((error) => Console.error(`::error::${error.message}`).pipe(Effect.as(1))), + ), + ), + ); } diff --git a/apps/cli/scripts/upload-release-assets.unit.test.ts b/apps/cli/scripts/upload-release-assets.unit.test.ts index 8c4c99d88c..58f41fc537 100644 --- a/apps/cli/scripts/upload-release-assets.unit.test.ts +++ b/apps/cli/scripts/upload-release-assets.unit.test.ts @@ -1,3 +1,5 @@ +import { it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; import { describe, expect, test } from "vitest"; import { main, @@ -19,6 +21,8 @@ const failed: RunResult = { }; const timedOut: RunResult = { exitCode: null, stdout: "", stderr: "", timedOut: true }; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const fastPolicy: RetryPolicy = { maxAttempts: 3, timeoutMs: 1_000, @@ -32,18 +36,21 @@ function fakeIo( ) { const state = { runs: [] as string[][], sleeps: [] as number[], logs: [] as string[] }; const io: ReleaseIo = { - run: async (argv) => { - state.runs.push(argv); - const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; - return script[target]?.shift() ?? ok; - }, - fileExists: async (path) => !options.missingFiles?.includes(path), - sleep: async (ms) => { - state.sleeps.push(ms); - }, - log: (line) => { - state.logs.push(line); - }, + run: (argv) => + Effect.sync(() => { + state.runs.push(argv); + const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; + return script[target]?.shift() ?? ok; + }), + fileExists: (path) => Effect.sync(() => !options.missingFiles?.includes(path)), + sleep: (ms) => + Effect.sync(() => { + state.sleeps.push(ms); + }), + log: (line) => + Effect.sync(() => { + state.logs.push(line); + }), }; return { io, state }; } @@ -76,104 +83,123 @@ describe("uploadAssets", () => { const third = { path: "install", name: "install" }; const assets = [first, second, third]; - test("uploads every asset once with --clobber when nothing fails", async () => { - const { io, state } = fakeIo(); - - await uploadAssets("v1.0.0", assets, io, fastPolicy); - - expect(state.runs).toEqual( - assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), - ); - expect(state.sleeps).toEqual([]); - expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); - }); - - test("retries a failed asset with growing pauses and moves on once it lands", async () => { - const { io, state } = fakeIo({ [second.path]: [failed, failed] }); - - await uploadAssets("v1.0.0", assets, io, fastPolicy); - - const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); - expect(secondAttempts).toHaveLength(3); - expect(state.sleeps).toEqual([10, 20]); - expect(state.logs).toContain( - `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, - ); - expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); - expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); - }); - - test("gives up on an asset after the last attempt and does not touch later assets", async () => { - const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); - - await expect(uploadAssets("v1.0.0", assets, io, fastPolicy)).rejects.toThrow( - `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, - ); - - expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); - expect(state.sleeps).toEqual([10, 20]); - }); - - test("treats a timed-out upload as a failed attempt", async () => { - const { io, state } = fakeIo({ [first.path]: [timedOut] }); - - await uploadAssets("v1.0.0", [first], io, fastPolicy); - - expect(state.runs).toHaveLength(2); - expect(state.logs[0]).toBe( - `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, - ); - }); - - test("fails before calling gh when an asset file is missing", async () => { - const { io, state } = fakeIo({}, { missingFiles: [first.path] }); - - await expect(uploadAssets("v1.0.0", [first], io, fastPolicy)).rejects.toThrow( - `Release asset ${first.path} does not exist.`, - ); - expect(state.runs).toEqual([]); - }); + it.effect("uploads every asset once with --clobber when nothing fails", () => + Effect.gen(function* () { + const { io, state } = fakeIo(); + + yield* uploadAssets("v1.0.0", assets, io, fastPolicy); + + expect(state.runs).toEqual( + assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), + ); + expect(state.sleeps).toEqual([]); + expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); + }), + ); + + it.effect("retries a failed asset with growing pauses and moves on once it lands", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [second.path]: [failed, failed] }); + + yield* uploadAssets("v1.0.0", assets, io, fastPolicy); + + const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); + expect(secondAttempts).toHaveLength(3); + expect(state.sleeps).toEqual([10, 20]); + expect(state.logs).toContain( + `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, + ); + expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); + expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); + }), + ); + + it.effect("gives up on an asset after the last attempt and does not touch later assets", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); + + const error = yield* Effect.flip(uploadAssets("v1.0.0", assets, io, fastPolicy)); + expect(error.message).toContain( + `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, + ); + + expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); + expect(state.sleeps).toEqual([10, 20]); + }), + ); + + it.effect("treats a timed-out upload as a failed attempt", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [first.path]: [timedOut] }); + + yield* uploadAssets("v1.0.0", [first], io, fastPolicy); + + expect(state.runs).toHaveLength(2); + expect(state.logs[0]).toBe( + `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, + ); + }), + ); + + it.effect("fails before calling gh when an asset file is missing", () => + Effect.gen(function* () { + const { io, state } = fakeIo({}, { missingFiles: [first.path] }); + + const error = yield* Effect.flip(uploadAssets("v1.0.0", [first], io, fastPolicy)); + expect(error.message).toContain(`Release asset ${first.path} does not exist.`); + expect(state.runs).toEqual([]); + }), + ); }); describe("uploadedAssetNames", () => { - test("keeps only assets GitHub reports as uploaded", async () => { - const view = { - assets: [ - { name: "checksums.txt", state: "uploaded" }, - { name: "install", state: "starter" }, - ], - }; - const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: JSON.stringify(view) }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["checksums.txt"]); - expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); - }); - - test("fails when the release cannot be read", async () => { - const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0 (exit 1: release not found).", - ); - }); - - test("names the release when gh prints something other than JSON", async () => { - const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0: gh returned invalid JSON.", - ); - }); - - test("names the release when the JSON has no usable assets array", async () => { - const { io } = fakeIo({ - "v1.0.0": [{ ...ok, stdout: JSON.stringify({ assets: [{ name: "install" }] }) }], - }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0: gh output has no assets array with name and state.", - ); - }); + it.effect("keeps only assets GitHub reports as uploaded", () => + Effect.gen(function* () { + const view = { + assets: [ + { name: "checksums.txt", state: "uploaded" }, + { name: "install", state: "starter" }, + ], + }; + const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: yield* encodeJson(view) }] }); + + expect(yield* uploadedAssetNames("v1.0.0", io)).toEqual(["checksums.txt"]); + expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); + }), + ); + + it.effect("fails when the release cannot be read", () => + Effect.gen(function* () { + const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain( + "Could not read assets of v1.0.0 (exit 1: release not found).", + ); + }), + ); + + it.effect("names the release when gh prints something other than JSON", () => + Effect.gen(function* () { + const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain("Could not read assets of v1.0.0: gh returned invalid JSON."); + }), + ); + + it.effect("names the release when the JSON has no usable assets array", () => + Effect.gen(function* () { + const { io } = fakeIo({ + "v1.0.0": [{ ...ok, stdout: yield* encodeJson({ assets: [{ name: "install" }] }) }], + }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain( + "Could not read assets of v1.0.0: gh output has no assets array with name and state.", + ); + }), + ); }); describe("missingAssets", () => { @@ -189,34 +215,40 @@ describe("main", () => { stdout: JSON.stringify({ assets: names.map((name) => ({ name, state: "uploaded" })) }), }); - test("prints usage for an unknown command or a missing version", async () => { - const { io, state } = fakeIo(); - - await expect(main(["publish", "--version", "1.0.0"], io)).resolves.toBe(2); - await expect(main(["upload"], io)).resolves.toBe(2); - expect(state.runs).toEqual([]); - expect(state.logs[0]).toContain("Usage:"); - }); - - test("verify succeeds when every expected asset is uploaded", async () => { - const names = releaseAssets("1.0.0").map((asset) => asset.name); - const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); - - await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(0); - expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); - }); - - test("verify fails and names each missing asset", async () => { - const names = releaseAssets("1.0.0") - .map((asset) => asset.name) - .filter((name) => name !== "checksums.txt" && name !== "install"); - const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); - - await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(1); - expect(state.logs).toEqual([ - "::error::Release v1.0.0 is missing assets or has incomplete uploads:", - " checksums.txt", - " install", - ]); - }); + it.effect("prints usage for an unknown command or a missing version", () => + Effect.gen(function* () { + const { io, state } = fakeIo(); + + expect(yield* main(["publish", "--version", "1.0.0"], io)).toBe(2); + expect(yield* main(["upload"], io)).toBe(2); + expect(state.runs).toEqual([]); + expect(state.logs[0]).toContain("Usage:"); + }), + ); + + it.effect("verify succeeds when every expected asset is uploaded", () => + Effect.gen(function* () { + const names = releaseAssets("1.0.0").map((asset) => asset.name); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + expect(yield* main(["verify", "--version", "1.0.0"], io)).toBe(0); + expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); + }), + ); + + it.effect("verify fails and names each missing asset", () => + Effect.gen(function* () { + const names = releaseAssets("1.0.0") + .map((asset) => asset.name) + .filter((name) => name !== "checksums.txt" && name !== "install"); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + expect(yield* main(["verify", "--version", "1.0.0"], io)).toBe(1); + expect(state.logs).toEqual([ + "::error::Release v1.0.0 is missing assets or has incomplete uploads:", + " checksums.txt", + " install", + ]); + }), + ); }); diff --git a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md index 4bc1f85932..1dec99b0f8 100644 --- a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md @@ -77,12 +77,6 @@ ## Telemetry Events Fired | Event | When | Notable properties / groups | @@ -124,7 +118,7 @@ ## Notes diff --git a/apps/cli/src/auth/command-credentials.layer.ts b/apps/cli/src/auth/command-credentials.layer.ts index df08dad47c..4ff06e2128 100644 --- a/apps/cli/src/auth/command-credentials.layer.ts +++ b/apps/cli/src/auth/command-credentials.layer.ts @@ -14,7 +14,7 @@ import type { PlatformError } from "effect/PlatformError"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../shared/auth/keyring-token.ts"; -import { DebugLogger, type DebugLoggerShape } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger, type DebugLoggerShape } from "../shared/output/debug-logger.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ACCESS_TOKEN_PATTERN, validateAccessToken } from "./access-token.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; @@ -52,9 +52,9 @@ const tryKeyringRead = ( if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); if (platform === "win32") { - const goWindowsValue = readGoWindowsTarget(module, account); - if (goWindowsValue && goWindowsValue.length > 0) { - return Option.some(normalizeKeyringToken(goWindowsValue)); + const windowsValue = readWindowsTarget(module, account); + if (windowsValue && windowsValue.length > 0) { + return Option.some(normalizeKeyringToken(windowsValue)); } } @@ -72,7 +72,7 @@ const tryKeyringWrite = ( Effect.try({ try: () => { if (platform === "win32") { - return writeGoWindowsTarget(module, account, token); + return writeWindowsTarget(module, account, token); } const entry = new module.Entry(KEYRING_SERVICE, account); @@ -99,7 +99,7 @@ const tryKeyringDelete = ( } if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { - deleted = deleteGoWindowsTarget(module, account) || deleted; + deleted = deleteWindowsTarget(module, account) || deleted; } return deleted; @@ -115,15 +115,15 @@ function readEntryPassword(entry: KeyringEntry): string | null { } } -function goWindowsCredentialTarget(account: string): string { +function windowsCredentialTarget(account: string): string { return `${KEYRING_SERVICE}:${account}`; } -function readGoWindowsTarget(module: KeyringModule, account: string): string | null { +function readWindowsTarget(module: KeyringModule, account: string): string | null { try { - const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credentials = module.findCredentials(KEYRING_SERVICE, windowsCredentialTarget(account)); const credential = credentials.find((item) => item.account === account); - return credential ? normalizeGoWindowsPassword(credential.password) : null; + return credential ? normalizeWindowsPassword(credential.password) : null; } catch { return null; } @@ -135,7 +135,7 @@ type WindowsTargetProbe = "present" | "absent" | "unknown"; function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { try { - const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credentials = module.findCredentials(KEYRING_SERVICE, windowsCredentialTarget(account)); // An empty password is an orphaned placeholder, not a real credential. const credential = credentials.find( (item) => item.account === account && item.password.length > 0, @@ -157,7 +157,7 @@ const deleteProbedWindowsTarget = ( Effect.gen(function* () { const result = yield* Effect.try(() => module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ).deleteCredential(), @@ -167,11 +167,11 @@ const deleteProbedWindowsTarget = ( return yield* Effect.fail(onFailure(cause)); }); -function normalizeGoWindowsPassword(value: string): string { +function normalizeWindowsPassword(value: string): string { const direct = normalizeKeyringToken(value); if (ACCESS_TOKEN_PATTERN.test(direct)) return direct; - // Go writes Windows CredentialBlob values as raw UTF-8 bytes, which the keyring search API + // Legacy Windows CredentialBlob values are raw UTF-8 bytes, which the keyring search API // can surface packed into UTF-16 code units; unpack each back into the original byte sequence. const bytes: number[] = []; for (let index = 0; index < value.length; index += 1) { @@ -183,10 +183,10 @@ function normalizeGoWindowsPassword(value: string): string { return Buffer.from(bytes).toString("utf8"); } -function writeGoWindowsTarget(module: KeyringModule, account: string, token: string): boolean { +function writeWindowsTarget(module: KeyringModule, account: string, token: string): boolean { try { const entry = module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ); @@ -197,10 +197,10 @@ function writeGoWindowsTarget(module: KeyringModule, account: string, token: str } } -function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean { +function deleteWindowsTarget(module: KeyringModule, account: string): boolean { try { const entry = module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ); @@ -298,12 +298,9 @@ const deleteAllKeyringEntries = ( Effect.sync(() => { if (platform === "win32") { try { - const entries = module.findCredentials( - KEYRING_SERVICE, - `${goWindowsCredentialTarget("")}*`, - ); + const entries = module.findCredentials(KEYRING_SERVICE, `${windowsCredentialTarget("")}*`); for (const { account } of entries) { - deleteGoWindowsTarget(module, account); + deleteWindowsTarget(module, account); } } catch { // best-effort @@ -379,7 +376,7 @@ const readFallbackFile = ( /** * Resolves an access token for an explicit profile account: env token → keyring (profile * account, then legacy account) → fallback file. Used by commands that reconcile a - * pflag-effective profile after `CommandCredentials` already captured a different one at + * profile resolved from raw argv after `CommandCredentials` already captured a different one at * construction. Fails with the same validation error as `resolveAccessToken` and propagates * credential storage failures. */ diff --git a/apps/cli/src/auth/command-credentials.layer.unit.test.ts b/apps/cli/src/auth/command-credentials.layer.unit.test.ts index cc6ddb296a..f65743b565 100644 --- a/apps/cli/src/auth/command-credentials.layer.unit.test.ts +++ b/apps/cli/src/auth/command-credentials.layer.unit.test.ts @@ -153,10 +153,10 @@ beforeEach(() => { const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); const VALID_V0_TOKEN = "sbp_v0_" + "c".repeat(40); -const encodeGoKeyringBase64 = (token: string) => +const encodeKeyringBase64 = (token: string) => `go-keyring-base64:${Buffer.from(token).toString("base64")}`; -const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; -const encodeGoWindowsPassword = (token: string) => { +const windowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; +const encodeWindowsPassword = (token: string) => { const bytes = Buffer.from(token, "utf8"); let encoded = ""; for (let index = 0; index < bytes.length; index += 2) { @@ -249,8 +249,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { ); }); - it.effect("decodes Go keyring base64 values from the keyring profile account", () => { - passwords.set("Supabase CLI/supabase", encodeGoKeyringBase64(VALID_TOKEN)); + it.effect("decodes legacy keyring base64 values from the keyring profile account", () => { + passwords.set("Supabase CLI/supabase", encodeKeyringBase64(VALID_TOKEN)); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -258,8 +258,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("reads Windows credentials created by Go keyring", () => { - passwords.set(goWindowsKey("supabase"), encodeGoWindowsPassword(VALID_TOKEN)); + it.effect("reads Windows credentials stored in the legacy target format", () => { + passwords.set(windowsKey("supabase"), encodeWindowsPassword(VALID_TOKEN)); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -268,8 +268,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("does not search Go Windows targets on other platforms", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + it.effect("does not search legacy Windows targets on other platforms", () => { + passwords.set(windowsKey("supabase"), VALID_TOKEN); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -440,11 +440,11 @@ describe("commandCredentialsLayer.saveAccessToken", () => { }).pipe(Effect.provide(makeLayer())), ); - it.effect("writes Windows credentials where Go keyring reads them", () => + it.effect("writes Windows credentials under the legacy target name", () => Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.get(goWindowsKey("supabase"))).toBe(VALID_TOKEN); + expect(passwords.get(windowsKey("supabase"))).toBe(VALID_TOKEN); expect(passwords.has("Supabase CLI/supabase")).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))), ); @@ -454,7 +454,7 @@ describe("commandCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); + expect(passwords.get(windowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); const fs = yield* FileSystem.FileSystem; const content = yield* fs.readFileString( @@ -558,7 +558,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { }, ); - it.effect("win32: no Go target credential → NotLoggedInError, nothing fabricated", () => { + it.effect("win32: no legacy target credential → NotLoggedInError, nothing fabricated", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -566,13 +566,13 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { if (exit._tag === "Failure") { expect(yield* encodeJson(exit.cause)).toContain("NotLoggedInError"); } - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); expect(withTargetCalls).toEqual([]); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("win32: an empty placeholder Go target → NotLoggedInError", () => { - passwords.set(goWindowsKey("supabase"), ""); + it.effect("win32: an empty placeholder legacy target → NotLoggedInError", () => { + passwords.set(windowsKey("supabase"), ""); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -668,43 +668,46 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("win32: deletes both the plain and the Go Windows target entries", () => { + it.effect("win32: deletes both the plain and the legacy Windows target entries", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + passwords.set(windowsKey("supabase"), VALID_TOKEN); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( - "win32: deletes a Go target whose blob getPassword/findCredentials can't decode", + "win32: deletes a legacy target whose blob getPassword/findCredentials can't decode", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); - opaqueAccounts.add(goWindowsKey("supabase")); + passwords.set(windowsKey("supabase"), VALID_TOKEN); + opaqueAccounts.add(windowsKey("supabase")); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, ); - it.effect("win32: a confirmed Go target whose delete returns false → DeleteTokenError", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); - failDeleteAccounts.add(goWindowsKey("supabase")); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* CommandCredentials; - const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); - } - expect(passwords.has(goWindowsKey("supabase"))).toBe(true); - }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); - }); + it.effect( + "win32: a confirmed legacy target whose delete returns false → DeleteTokenError", + () => { + passwords.set(windowsKey("supabase"), VALID_TOKEN); + failDeleteAccounts.add(windowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* CommandCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); + } + expect(passwords.has(windowsKey("supabase"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); it.effect("win32: an enumeration hiccup alone does not block logout", () => { throwOnFindCredentials = true; @@ -719,7 +722,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { "win32: an enumeration failure whose target also fails to delete → DeleteTokenError", () => { throwOnFindCredentials = true; - failDeleteAccounts.add(goWindowsKey("supabase")); + failDeleteAccounts.add(windowsKey("supabase")); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -774,50 +777,50 @@ describe("commandCredentialsLayer.deleteAllProjectCredentials", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("win32: deletes Go target-shaped project credentials", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); + it.effect("win32: deletes legacy target-shaped project credentials", () => { + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(windowsKey("abcdefghijklmnopqrs2"), "secret-2"); return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* CommandCredentials; yield* deleteAllProjectCredentials; - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs2"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( - "win32: a single undecodable entry aborts the Go target sweep without failing logout", + "win32: a single undecodable entry aborts the legacy target sweep without failing logout", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); - opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(windowsKey("abcdefghijklmnopqrs2"), "secret-2"); + opaqueAccounts.add(windowsKey("abcdefghijklmnopqrs1")); return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); expect(exit._tag).toBe("Success"); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(true); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs2"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, ); }); describe("commandCredentialsLayer.deleteProjectCredential", () => { - it.effect("win32: deletes a Go target-shaped project credential", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); + it.effect("win32: deletes a legacy target-shaped project credential", () => { + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "db-password"); return Effect.gen(function* () { const { deleteProjectCredential } = yield* CommandCredentials; const deleted = yield* deleteProjectCredential("abcdefghijklmnopqrs1"); expect(deleted).toBe(true); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( "win32: a confirmed project credential whose delete fails → CredentialDeleteError", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); - throwOnDeleteAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "db-password"); + throwOnDeleteAccounts.add(windowsKey("abcdefghijklmnopqrs1")); return Effect.gen(function* () { const { deleteProjectCredential } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 89f1e1a039..112f224e19 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -4,7 +4,7 @@ import * as HttpClient from "effect/http/HttpClient"; import { CLI_VERSION } from "../shared/cli/version.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { DebugLogger } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { validateAccessToken } from "./access-token.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index 1203e1aba1..ac98a9add2 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -40,7 +40,6 @@ function mockCliSettings(opts: { projectHost?: string; }) { return Layer.succeed(CommandSettings, { - dbPassword: Option.none(), githubToken: Option.none(), workdirEnvValue: Option.none(), profile: opts.profile ?? "supabase", @@ -263,35 +262,32 @@ describe("commandPlatformApiLayer", () => { }); }); - it.effect( - "fails with the invalid-token error when the env token is malformed (Go parity)", - () => { - const http = captureRequests(); - const layer = commandPlatformApiLayer.pipe( - Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), - Layer.provide(mockCredentials(Option.none())), - Layer.provide(http.layer), - withBaseDeps(), + it.effect("fails with the invalid-token error when the env token is malformed", () => { + const http = captureRequests(); + const layer = commandPlatformApiLayer.pipe( + Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps(), + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const api = yield* CommandPlatformApi; + return yield* api.v1.listAllProjects(); + }).pipe(Effect.provide(layer)), ); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - Effect.gen(function* () { - const api = yield* CommandPlatformApi; - return yield* api.v1.listAllProjects(); - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = yield* encodeJson(exit.cause); - expect(errorJson).toContain("InvalidAccessTokenError"); - expect(errorJson).toContain("Invalid access token format"); - } - expect(http.requests).toHaveLength(0); - }); - }, - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = yield* encodeJson(exit.cause); + expect(errorJson).toContain("InvalidAccessTokenError"); + expect(errorJson).toContain("Invalid access token format"); + } + expect(http.requests).toHaveLength(0); + }); + }); - it.effect("sends Go-style User-Agent and no X-Supabase-Command headers", () => { + it.effect("sends the CLI User-Agent and no X-Supabase-Command headers", () => { const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( Layer.provide( diff --git a/apps/cli/src/auth/http-debug.layer.ts b/apps/cli/src/auth/http-debug.layer.ts index e1bdc82b69..ce6ca1bda8 100644 --- a/apps/cli/src/auth/http-debug.layer.ts +++ b/apps/cli/src/auth/http-debug.layer.ts @@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/http"; import * as HttpClient from "effect/http/HttpClient"; import { dohFetchLayer } from "../command-internal/http-dns.ts"; -import { DebugLogger } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; /** * Query parameters that mean the URL *is* a credential. diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 35640ce142..1ad7b05f30 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -16,7 +16,7 @@ describe("supabase __complete", () => { ); expect(exitCode).toBe(0); const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines[0]).toBe("list\tList local files and the target database's migration history"); expect(lines.at(-1)).toBe(":4"); }), E2E_TIMEOUT_MS, @@ -115,7 +115,9 @@ describe("supabase __complete", () => { ); expect(completionFailure.exitCode).toBe(1); expect(completionFailure.stdout).toBe(""); - expect(completionFailure.stderr).toContain("must be 0 or 1"); + expect(completionFailure.stderr).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="invalid" (sets experimental.stack): expected true or false.', + ); const invalidEnv = yield* runSupabaseEffect(["start", "--output-format=json"], { cwd: project.dir, diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts new file mode 100644 index 0000000000..288f2b35b0 --- /dev/null +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { fixtureGroup, fixtureLeaf, fixtureRoot } from "../../tests/helpers/remote-flag-fixture.ts"; +import { CompletionDirective, respondToComplete } from "./complete.ts"; + +const tree = fixtureRoot( + fixtureGroup( + "db", + fixtureLeaf("push", "selectable"), + fixtureLeaf("pull", "selectable", { positionals: ["optional"] }), + fixtureLeaf("query", "selectable", { positionals: ["required"] }), + fixtureLeaf("cp", "selectable", { positionals: ["required", "variadic"] }), + fixtureLeaf("deploy", "hosted"), + ), +); + +const complete = (...words: ReadonlyArray) => + respondToComplete(tree, ["__complete", ...words]); + +const names = (...words: ReadonlyArray) => + complete(...words)?.candidates.map((candidate) => candidate.name); + +const noValueCandidates = { candidates: [], directive: CompletionDirective.NoFileComp }; +const positionalCandidates = { candidates: [], directive: CompletionDirective.Default }; + +const PROJECT_REF = "abcdefghijklmnopqrst"; + +describe("--remote flag-name completion", () => { + it("offers --remote= so the next word is typed as its value", () => { + expect(names("db", "push", "--rem")).toEqual(["--remote="]); + }); + + it("offers --remote= after positionals", () => { + expect(names("db", "pull", "add_profiles", "--rem")).toEqual(["--remote="]); + }); + + it("asks the shell not to add a space after --remote=", () => { + expect(complete("db", "push", "--rem")?.directive).toBe( + CompletionDirective.NoFileComp | CompletionDirective.NoSpace, + ); + }); + + it("keeps the space when other flags are offered next to --remote=", () => { + expect(names("db", "push", "--")).toContain("--remote="); + expect(names("db", "push", "--")?.length).toBeGreaterThan(1); + expect(complete("db", "push", "--")?.directive).toBe(CompletionDirective.NoFileComp); + }); + + it("keeps the space after a flag name that takes no value", () => { + expect(complete("db", "push", "--ot")?.directive).toBe(CompletionDirective.NoFileComp); + }); + + it("stops offering --remote once it is given", () => { + expect(names("db", "push", "--remote", "--rem")).toEqual([]); + expect(names("db", "push", "--remote=feature-x", "--rem")).toEqual([]); + }); +}); + +describe("--remote= value completion", () => { + it.each(["--remote=", "--remote=feature-x", "--remote=postgresql://u:p@host:5432/db"])( + "takes any text after the equals sign and offers no files (%s)", + (token) => { + expect(complete("db", "push", token)).toEqual(noValueCandidates); + }, + ); + + it("keeps completing flags after a branch name given with an equals sign", () => { + expect(names("db", "push", "--remote=feature-x", "--o")).toEqual(["--other"]); + }); +}); + +describe("the token after a bare --remote", () => { + it("is the value on a command with no positional", () => { + expect(complete("db", "push", "--remote", "")).toEqual(noValueCandidates); + expect(complete("db", "deploy", "--remote", "feat")).toEqual(noValueCandidates); + }); + + it("is the value when it is the linked boolean literal y, even with a free positional slot", () => { + expect(complete("db", "pull", "--remote", "y")).toEqual(noValueCandidates); + expect(complete("db", "query", "--remote", "y")).toEqual(noValueCandidates); + }); + + it("is the positional while the command has a free positional slot", () => { + expect(complete("db", "pull", "--remote", "")).toEqual(positionalCandidates); + expect(complete("db", "query", "--remote", "sel")).toEqual(positionalCandidates); + }); + + it("is the value when it is a database URL, even with a free positional slot", () => { + expect(complete("db", "pull", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); + expect(complete("db", "query", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); + }); + + it("is the positional when it is a project ref and the command has a free positional slot", () => { + expect(complete("db", "pull", "--remote", PROJECT_REF)).toEqual(positionalCandidates); + }); + + it("is the value once the positionals are filled", () => { + expect(complete("db", "pull", "add_profiles", "--remote", "")).toEqual(noValueCandidates); + expect(complete("db", "query", "select 1", "--remote", "")).toEqual(noValueCandidates); + }); + + it("is a positional when the command takes a variadic one and the word could be an operand", () => { + expect(complete("db", "cp", "a", "b", "--remote", "")).toEqual(positionalCandidates); + expect(complete("db", "cp", "a", "--remote", "feat")).toEqual(positionalCandidates); + }); + + it("is the value on a variadic command once it is a database URL", () => { + expect(complete("db", "cp", "a", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); + }); + + it("is a positional on a variadic command when it is a project ref", () => { + expect(complete("db", "cp", "a", "--remote", PROJECT_REF)).toEqual(positionalCandidates); + }); + + it("does not stop flag completion from running after the bare switch", () => { + expect(names("db", "pull", "--remote", "--o")).toEqual(["--other"]); + }); +}); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index b171822fb3..190c764622 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -8,11 +8,14 @@ import process from "node:process"; import { QUERY_OUTPUT_FORMATS, RESOURCE_OUTPUT_FORMATS, -} from "../command-internal/go-output-flag.ts"; +} from "../command-internal/output-formats.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; -import { isValidBase0Int64, parseUintBase0 } from "../command-internal/parse-uint.ts"; -import type { ProjectStateMode } from "../command-internal/project-state-mode.ts"; +import { REMOTE_FLAG_NAME } from "../command-internal/target/remote-vocabulary.ts"; +import { isValidBase0Int64, parseUintBase0 } from "../shared/config/parse-uint.ts"; +import type { ProjectStateMode } from "../shared/config/project-state-mode.ts"; import { parseStringSliceFlag } from "../command-internal/string-slice-flag.ts"; +import { joinRemoteOperands, type RemoteArgvLeaf } from "../shared/cli/remote-argv.ts"; +import { remoteArgvLeafFor } from "../shared/cli/run.ts"; import { withAnalyticsContext } from "../shared/telemetry/analytics-context.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { @@ -28,9 +31,10 @@ import { ttyLayer } from "../shared/runtime/tty.layer.ts"; import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { analyticsLayerFor } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; /** - * Implements the shell completion protocol that cobra-generated scripts (`supabase + * Implements the shell completion protocol that the generated scripts (`supabase * completion {bash,zsh,fish,powershell}`) call into via `supabase __complete`/ * `__completeNoDesc` on every tab press. Completion argv can contain partial or malformed * flag tokens (e.g. `--de` mid-word), so this bypasses the structured CLI parser and @@ -50,6 +54,7 @@ export interface CompletionResult { /** Bit flags for the shell completion protocol's directive value. */ export const CompletionDirective = { Default: 0, + NoSpace: 2, NoFileComp: 4, FilterFileExt: 8, } as const; @@ -80,6 +85,7 @@ export interface ClassifyCompletionInput { readonly trimmedArgs: ReadonlyArray; readonly toComplete: string; readonly inScopeFlags: ReadonlyArray; + readonly remoteLeaf?: RemoteArgvLeaf | undefined; } export interface CompleteDeps { @@ -128,10 +134,8 @@ function flattenSubcommands(command: Command.Command.Any): ReadonlyArray` - * type, so this narrows through the same runtime-guard idiom as - * `CommandInternal`. + * `choiceKeys` (a `Choice` primitive's valid values) is attached via `Object.assign`, so it is + * absent from the public `Primitive` type and narrows via the same guard as `CommandInternal`. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray; @@ -348,12 +352,20 @@ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet = new Set([ "completion powershell", ]); +const REMOTE_FLAG_CANDIDATE = `--${REMOTE_FLAG_NAME}=`; + +function flagNameDirective(candidates: ReadonlyArray): number { + return candidates.length > 0 && candidates.every((candidate) => candidate.name.endsWith("=")) + ? CompletionDirective.NoFileComp | CompletionDirective.NoSpace + : CompletionDirective.NoFileComp; +} + function flagNameCandidates( flag: FlagDescriptor, toComplete: string, ): ReadonlyArray { const candidates: Array = []; - const long = `--${flag.name}`; + const long = flag.name === REMOTE_FLAG_NAME ? REMOTE_FLAG_CANDIDATE : `--${flag.name}`; if (long.startsWith(toComplete)) candidates.push({ name: long, description: flag.description }); for (const alias of flag.aliases) { if (alias.length !== 1) continue; @@ -474,7 +486,7 @@ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ ]); /** - * Flags validated against Go duration syntax (see `isValidGoDuration`) rather than this + * Flags validated against duration syntax (see `isValidDuration`) rather than this * tree's plain `Flag.String` declarations. */ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ @@ -485,8 +497,8 @@ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ /** `--exp` (`gen bearer-jwt`) is validated as an RFC 3339 timestamp, not a plain string. */ const COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); -/** Nanosecond scale for each unit the Go duration grammar accepts. */ -const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ +/** Nanosecond scale for each unit the duration grammar accepts. */ +const DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["ns", 1n], ["us", 1_000n], ["µs", 1_000n], // U+00B5 micro sign @@ -497,23 +509,23 @@ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["h", 3_600_000_000_000n], ]); -// Go's duration grammar accumulates into a `uint64` (range-checked against `1<<63` +// The duration grammar accumulates into a `uint64` (range-checked against `1<<63` // mid-parse) and narrows to the `int64` max in the final non-negative check below. -const GO_DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; -const GO_DURATION_MAX_INT64 = (1n << 63n) - 1n; +const DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; +const DURATION_MAX_INT64 = (1n << 63n) - 1n; function isAsciiDigit(char: string | undefined): boolean { return char !== undefined && char >= "0" && char <= "9"; } /** - * Validates the Go duration syntax (`1h30m`, `1.5h`, `.5s`) these flags accept, returning a + * Validates the duration syntax (`1h30m`, `1.5h`, `.5s`) these flags accept, returning a * boolean verdict rather than a parsed value. Uses `BigInt` for the integer/fraction - * accumulators to match Go's `uint64` overflow semantics exactly, and a plain `Number` for - * the one step Go performs in `float64` — a JS `number` is itself an IEEE-754 double, so + * accumulators to get `uint64` overflow semantics, and a plain `Number` for + * the one step performed in `float64` — a JS `number` is itself an IEEE-754 double, so * this round-trips bit-for-bit rather than merely approximating it. */ -function isValidGoDuration(value: string): boolean { +function isValidDuration(value: string): boolean { let rest = value; let negative = false; if (rest.length > 0 && (rest[0] === "-" || rest[0] === "+")) { @@ -531,9 +543,9 @@ function isValidGoDuration(value: string): boolean { let i = 0; let intPart = 0n; while (isAsciiDigit(rest[i])) { - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; intPart = intPart * 10n + BigInt(rest[i] as string); - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND) return false; i++; } const hasIntDigits = i > 0; @@ -549,11 +561,11 @@ function isValidGoDuration(value: string): boolean { let fracOverflowed = false; while (isAsciiDigit(rest[j])) { if (!fracOverflowed) { - if (fracPart > GO_DURATION_MAX_INT64 / 10n) { + if (fracPart > DURATION_MAX_INT64 / 10n) { fracOverflowed = true; } else { const next = fracPart * 10n + BigInt(rest[j] as string); - if (next > GO_DURATION_UINT64_OVERFLOW_BOUND) { + if (next > DURATION_UINT64_OVERFLOW_BOUND) { fracOverflowed = true; } else { fracPart = next; @@ -572,24 +584,24 @@ function isValidGoDuration(value: string): boolean { let k = 0; while (k < rest.length && !(rest[k] === "." || isAsciiDigit(rest[k]))) k++; if (k === 0) return false; // missing unit - const unitNanos = GO_DURATION_UNIT_NANOS.get(rest.slice(0, k)); + const unitNanos = DURATION_UNIT_NANOS.get(rest.slice(0, k)); rest = rest.slice(k); if (unitNanos === undefined) return false; // unknown unit - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; let termNanos = intPart * unitNanos; if (fracPart > 0n) { const fractional = Number(fracPart) * (Number(unitNanos) / Number(scale)); termNanos += BigInt(Math.trunc(fractional)); - if (termNanos > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (termNanos > DURATION_UINT64_OVERFLOW_BOUND) return false; } total += termNanos; - if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (total > DURATION_UINT64_OVERFLOW_BOUND) return false; } // The negative side already got the larger `1<<63` bound above (int64's two's-complement // asymmetry); only the non-negative case needs this final check. - return negative || total <= GO_DURATION_MAX_INT64; + return negative || total <= DURATION_MAX_INT64; } /** @@ -599,11 +611,11 @@ function isValidGoDuration(value: string): boolean { * through `DateTime.makeUnsafe`, which — unlike the `Date` constructor — doesn't special-case * a 0-99 year into 1900+year. */ -const GO_RFC3339_PATTERN = +const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; -function isValidGoRfc3339(value: string): boolean { - const match = GO_RFC3339_PATTERN.exec(value); +function isValidRfc3339(value: string): boolean { + const match = RFC3339_PATTERN.exec(value); if (match === null) return false; const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = match; const y = Number(year); @@ -650,6 +662,18 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray : RESOURCE_OUTPUT_FORMATS; } +function remoteTakesWord( + remoteLeaf: RemoteArgvLeaf | undefined, + trimmedArgs: ReadonlyArray, + toComplete: string, +): boolean { + if (remoteLeaf === undefined) return false; + // An empty word has no shape yet, so it is read as the branch name it may become. + const word = toComplete === "" ? "branch" : toComplete; + const joined = joinRemoteOperands([...trimmedArgs, word], remoteLeaf); + return joined[joined.length - 1] === `--${REMOTE_FLAG_NAME}=${word}`; +} + /** * Validates a flag's value; an invalid value returns zero candidates with the Default * directive, exactly like an unresolved flag name. The command-dependent overrides @@ -662,15 +686,16 @@ function isValidFlagValue( flag: FlagDescriptor, value: string, ): boolean { + if (flag.name === REMOTE_FLAG_NAME) return true; const key = `${matchedPath.join(" ")}:${flag.name}`; if (COMPLETION_UINT_FLAGS.has(key)) { return "value" in parseUintBase0(value); } if (COMPLETION_DURATION_FLAGS.has(key)) { - return isValidGoDuration(value); + return isValidDuration(value); } if (COMPLETION_RFC3339_FLAGS.has(key)) { - return isValidGoRfc3339(value); + return isValidRfc3339(value); } if ( flag.isVariadic && @@ -681,7 +706,7 @@ function isValidFlagValue( } switch (flag.primitiveTag) { case "Boolean": - return parseGoBool(value) !== undefined; + return parseBoolLiteral(value) !== undefined; case "Choice": if (flag.name === "output") { return outputFlagChoiceKeys(matchedPath).includes(value); @@ -797,6 +822,9 @@ function flagValueCompletion( matchedPath: ReadonlyArray, flagName: string | undefined, ): CompletionResult { + if (flagName === REMOTE_FLAG_NAME) { + return { candidates: [], directive: CompletionDirective.NoFileComp }; + } const key = flagName === undefined ? undefined : `${matchedPath.join(" ")}:${flagName}`; const extensions = key === undefined ? undefined : COMPLETION_FLAG_FILE_EXTENSIONS.get(key); if (extensions !== undefined) { @@ -851,6 +879,7 @@ function helpArgumentCandidates( */ export function classifyCompletion(input: ClassifyCompletionInput): CompletionResult { const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; + const { remoteLeaf } = input; const isAtRoot = matchedPath.length === 0; if (findUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags, matchedPath) !== undefined) { @@ -893,12 +922,12 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe ); // If any required flag is still unset, only required flags are offered. if (requiredCandidates.length > 0) { - return { candidates: requiredCandidates, directive: CompletionDirective.NoFileComp }; + return { candidates: requiredCandidates, directive: flagNameDirective(requiredCandidates) }; } const candidates = inScopeFlags .filter((flag) => !flag.hidden && (!changedFlagNames.has(flag.name) || flag.isVariadic)) .flatMap((flag) => flagNameCandidates(flag, toComplete)); - return { candidates, directive: CompletionDirective.NoFileComp }; + return { candidates, directive: flagNameDirective(candidates) }; } // Case 2: flag-VALUE completion. @@ -929,6 +958,12 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe if (!resolved.isBoolean) { return flagValueCompletion(matchedPath, resolved.name); } + if ( + resolved.name === REMOTE_FLAG_NAME && + remoteTakesWord(remoteLeaf, trimmedArgs, toComplete) + ) { + return flagValueCompletion(matchedPath, resolved.name); + } // A resolved boolean precedingToken falls through to Case 3, since it never // consumed a value. } @@ -1013,18 +1048,12 @@ export function respondToComplete( trimmedArgs, toComplete, inScopeFlags, + remoteLeaf: remoteArgvLeafFor(root, [...trimmedArgs, toComplete]), }); } -const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ - "1", - "t", - "T", - "TRUE", - "true", - "True", -]); -const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ +const TRUE_BOOL_SPELLINGS: ReadonlySet = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ "0", "f", "F", @@ -1033,16 +1062,15 @@ const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ "False", ]); -function parseGoBool(value: string): boolean | undefined { - if (GO_TRUE_BOOL_SPELLINGS.has(value)) return true; - if (GO_FALSE_BOOL_SPELLINGS.has(value)) return false; +function parseBoolLiteral(value: string): boolean | undefined { + if (TRUE_BOOL_SPELLINGS.has(value)) return true; + if (FALSE_BOOL_SPELLINGS.has(value)) return false; return undefined; } /** * `argv[0] === "__completeNoDesc"` always wins; otherwise `SUPABASE_COMPLETION_DESCRIPTIONS` - * is checked first, falling back to `COBRA_COMPLETION_DESCRIPTIONS` when unset or empty. An - * unparseable value is ignored, leaving the `argv[0]`-derived default in place. + * is consulted. An unparseable value is ignored, leaving the `argv[0]`-derived default in place. */ export function resolveIncludeDescriptions( argv0: string | undefined, @@ -1050,8 +1078,8 @@ export function resolveIncludeDescriptions( ): boolean { let includeDescriptions = argv0 !== "__completeNoDesc"; if (includeDescriptions) { - const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS || env.COBRA_COMPLETION_DESCRIPTIONS || ""; - const parsed = parseGoBool(raw); + const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS ?? ""; + const parsed = parseBoolLiteral(raw); if (parsed !== undefined) includeDescriptions = parsed; } return includeDescriptions; @@ -1195,7 +1223,7 @@ export function defaultCompleteDeps( root, routingFailure, argv: process.argv.slice(2), - env: process.env, + env: ambientEnvironment(), stdoutWrite: (message) => { process.stdout.write(message); }, diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 9a19e81766..e8cc3f4505 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -92,15 +92,11 @@ describe("respondToComplete", () => { }); it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { - const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--l"]); - expect(atGroup?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--linked", "--local"]), - ); + const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--rem"]); + expect(atGroup?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); - const atLeaf = respondToComplete(rootCommand, ["__complete", "seed", "buckets", "--l"]); - expect(atLeaf?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--linked", "--local"]), - ); + const atLeaf = respondToComplete(rootCommand, ["__complete", "seed", "buckets", "--rem"]); + expect(atLeaf?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); }); it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { @@ -178,9 +174,9 @@ describe("respondToComplete", () => { "squash", "--version", "20240101000000", - "--l", + "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote="); }); it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { @@ -228,7 +224,7 @@ describe("respondToComplete", () => { ); }); - it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { + it("does not treat a surviving bare `-` leftover as an unmatched command (it is dropped)", () => { const result = respondToComplete(rootCommand, ["__complete", "-", "--d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--debug", "--dns-resolver"]), @@ -257,7 +253,7 @@ describe("respondToComplete", () => { }); }); - it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { + it("short-circuits on a flag marked required even though it is optional at parse time", () => { // Required-ness comes from the explicit `COMPLETION_REQUIRED_FLAGS` table, not from // inferring it off `Flag.optional`. const result = respondToComplete(rootCommand, [ @@ -378,7 +374,7 @@ describe("respondToComplete", () => { }); }); - describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { + describe("attached shorthand values resolve via the strict flag parser (CLI-1965 review)", () => { it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { const result = respondToComplete(rootCommand, [ "__complete", @@ -388,7 +384,7 @@ describe("respondToComplete", () => { "--p", ]); expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--profile", "--project-ref", "--prune"]), + expect.arrayContaining(["--profile", "--prune"]), ); expect(result?.directive).toBe(CompletionDirective.NoFileComp); }); @@ -504,7 +500,7 @@ describe("respondToComplete", () => { }); }); - describe("uint-backed flags reject a leading sign like real pflag's ParseUint (CLI-1965 review)", () => { + describe("uint-backed flags reject a leading sign (CLI-1965 review)", () => { it.each([ { path: ["functions", "deploy"], flag: "jobs" }, { path: ["migration", "down"], flag: "last" }, @@ -527,7 +523,7 @@ describe("respondToComplete", () => { ]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--debug", "--dns-resolver", "--db-url"]), + expect.arrayContaining(["--debug", "--dns-resolver"]), ); }); }); @@ -557,9 +553,9 @@ describe("respondToComplete", () => { "query", "--output", "table", - "--li", + "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote="); }); it("accepts the resource-command values (env/pretty/toml/yaml) outside db query", () => { @@ -568,14 +564,14 @@ describe("respondToComplete", () => { expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); - it("still accepts json everywhere — the one value both Go enums share", () => { + it("still accepts json everywhere — the one value every output format enum shares", () => { const result = respondToComplete(rootCommand, ["__complete", "--output", "json", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); }); - describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { + describe("flag values are validated with the strict flag parser (CLI-1965 review)", () => { it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { const result = respondToComplete(rootCommand, [ "__complete", @@ -583,12 +579,10 @@ describe("respondToComplete", () => { "restore", "--timestamp", "0x10", - "--p", + "--r", ]); - expect(result?.directive).toBe(CompletionDirective.NoFileComp); - expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--profile", "--project-ref"]), - ); + expect(result?.directive).toBe(CompletionDirective.NoFileComp | CompletionDirective.NoSpace); + expect(result?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); }); it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { @@ -625,14 +619,14 @@ describe("respondToComplete", () => { expect(min?.candidates.map((c) => c.name)).toContain("--profile"); }); - it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { + it("rejects a malformed value for duration flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { const queryTimeout = respondToComplete(rootCommand, [ "__complete", "gen", "types", "--query-timeout", "bogus", - "--l", + "--rem", ]); expect(queryTimeout).toEqual({ candidates: [], @@ -645,9 +639,9 @@ describe("respondToComplete", () => { "types", "--query-timeout", "5s", - "--l", + "--rem", ]); - expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--local"); + expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--remote="); const validFor = respondToComplete(rootCommand, [ "__complete", @@ -674,14 +668,14 @@ describe("respondToComplete", () => { expect(validForValid?.candidates.map((c) => c.name)).toContain("--profile"); }); - it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { + it("rejects a duration one unit past the int64 nanosecond range for duration flags", () => { const overflow = respondToComplete(rootCommand, [ "__complete", "gen", "types", "--query-timeout", "2562048h", - "--l", + "--rem", ]); expect(overflow).toEqual({ candidates: [], directive: CompletionDirective.Default }); @@ -691,12 +685,12 @@ describe("respondToComplete", () => { "types", "--query-timeout", "2562047h47m16.854775807s", - "--l", + "--rem", ]); - expect(max?.candidates.map((c) => c.name)).toContain("--local"); + expect(max?.candidates.map((c) => c.name)).toContain("--remote="); }); - it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { + it("rejects a malformed value for the time flag (gen bearer-jwt --exp, RFC3339 only)", () => { const invalid = respondToComplete(rootCommand, [ "__complete", "gen", @@ -970,7 +964,7 @@ describe("respondToComplete", () => { }); }); - it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { + it("returns undefined for zero completion args", () => { expect(respondToComplete(rootCommand, ["__complete"])).toBeUndefined(); }); @@ -1079,7 +1073,7 @@ describe("collectInScopeFlags", () => { ); }); - it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { + it("orders inherited flags before the command's own flags — alphabetical within each block, not declaration order (CLI-1965 review)", () => { const { commandChain } = resolveCommandPath(rootCommand, ["db", "dump"]); const names = collectInScopeFlags(rootCommand, commandChain).map((flag) => flag.name); @@ -1096,7 +1090,7 @@ describe("collectInScopeFlags", () => { expect(ownBlock).toContain("help"); }); - it("orders root's own flags alphabetically end-to-end (InheritedFlags() is empty at root)", () => { + it("orders root's own flags alphabetically end-to-end (no inherited flags at root)", () => { const atRoot = collectInScopeFlags( rootCommand, resolveCommandPath(rootCommand, []).commandChain, @@ -1139,7 +1133,6 @@ describe("resolveIncludeDescriptions", () => { expect( resolveIncludeDescriptions("__completeNoDesc", { SUPABASE_COMPLETION_DESCRIPTIONS: "true", - COBRA_COMPLETION_DESCRIPTIONS: "true", }), ).toBe(false); }); @@ -1150,9 +1143,9 @@ describe("resolveIncludeDescriptions", () => { ).toBe(false); }); - it("falls back to the generic COBRA_COMPLETION_DESCRIPTIONS when the program-specific var is unset", () => { + it("ignores COBRA_COMPLETION_DESCRIPTIONS", () => { expect(resolveIncludeDescriptions("__complete", { COBRA_COMPLETION_DESCRIPTIONS: "0" })).toBe( - false, + true, ); }); @@ -1163,15 +1156,6 @@ describe("resolveIncludeDescriptions", () => { }), ).toBe(true); }); - - it("prioritizes the program-specific var over the generic one when both are set and conflict", () => { - expect( - resolveIncludeDescriptions("__complete", { - SUPABASE_COMPLETION_DESCRIPTIONS: "true", - COBRA_COMPLETION_DESCRIPTIONS: "false", - }), - ).toBe(true); - }); }); describe("formatCompletionResponse", () => { @@ -1291,14 +1275,17 @@ describe("tryComplete", () => { root: undefined, routingFailure: Cause.fail( new StackRoutingError({ - message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + message: + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', }), ), }); expect(yield* tryComplete(deps)).toBe(true); expect(stdoutWrites).toEqual([]); expect(stderrWrites).toHaveLength(1); - expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + expect(stderrWrites[0]).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', + ); expect(stderrWrites[0]).toContain( "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=1 to enable stack commands, or 0 to use legacy start/stop/status.", ); diff --git a/apps/cli/src/cli/main.e2e.test.ts b/apps/cli/src/cli/main.e2e.test.ts index 172d71114d..545f989f98 100644 --- a/apps/cli/src/cli/main.e2e.test.ts +++ b/apps/cli/src/cli/main.e2e.test.ts @@ -18,7 +18,9 @@ describe("CLI feature routing", () => { expect(result.exitCode).toBe(1); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1"); + expect(result.stderr).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', + ); }), ), ), diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 4975ac6bee..4198aeed5a 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -8,6 +8,7 @@ import { analyticsLayerFor } from "../telemetry/analytics.layer.ts"; import { defaultCompleteDeps, tryComplete } from "./complete.ts"; import { resolveProjectStateSelection } from "../command-internal/stack-backend.ts"; import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { cliEntrypointForFeatures } from "./root.ts"; const args = await Effect.runPromise( @@ -22,15 +23,13 @@ const selectionExit = await Effect.runPromiseExit( const { projectState, stackBackend } = yield* resolveProjectStateSelection({ args, cwd: process.cwd(), - env: process.env, }); const computeEnabled = yield* resolveComputeEnabled({ args, cwd: process.cwd(), - env: process.env, }); return { projectState, stackBackend, computeEnabled }; - }).pipe(Effect.provide(BunServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer))), ); const { rootCommand: selectedRoot, agentDefaultOutputFormat } = cliEntrypointForFeatures( Exit.isSuccess(selectionExit) diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 4d2e43ee0a..93baf60356 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -1,4 +1,4 @@ -import { Effect, type Exit, Layer, Option } from "effect"; +import { Effect, type Exit, Layer } from "effect"; import { Command } from "effect/cli"; import { backupsCommand } from "../commands/backups/backups.command.ts"; import { bootstrapCommand } from "../commands/bootstrap/bootstrap.command.ts"; @@ -18,11 +18,11 @@ import { stackStopCommand } from "../commands/experimental/stack/stop/stop.comma import { stackStatusCommand } from "../commands/experimental/stack/status/status.command.ts"; import type { StackBackend } from "../command-internal/stack-backend.ts"; import { isFunctionsServePath, stackBackendLayer } from "../command-internal/stack-backend.ts"; -import type { ExperimentalFeatureFlagError } from "../command-internal/experimental-feature.ts"; +import type { ExperimentalFeatureFlagError } from "../shared/config/experimental-feature.ts"; import { type ProjectStateMode, projectStateModeLayer, -} from "../command-internal/project-state-mode.ts"; +} from "../shared/config/project-state-mode.ts"; import { computeCommand } from "../commands/experimental/compute/compute.command.ts"; import { feedbackCommand } from "../commands/feedback/feedback.command.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; @@ -57,9 +57,11 @@ import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { vanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { whoamiCommand } from "../commands/whoami/whoami.command.ts"; import { CLI_VERSION, cliBuildChannel } from "../shared/cli/version.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { machineErrorContextLayer } from "../shared/output/machine-error-context.layer.ts"; import { outputLayerFor } from "../shared/output/output.layer.ts"; +import { isMachineResourceFormat } from "../shared/output/resource-format.ts"; import { quietProgressTextOutputLayer } from "../output/quiet-progress-text-output.layer.ts"; -import { makeGoProxyLayer } from "../command-internal/go-proxy.layer.ts"; import { AiTool } from "../shared/telemetry/ai-tool.service.ts"; import { aiToolLayer } from "../shared/telemetry/ai-tool.layer.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; @@ -70,16 +72,8 @@ import type { OutputFormat } from "../shared/output/types.ts"; import { ROOT_GLOBAL_FLAGS, AgentFlag, - CreateTicketFlag, - DebugFlag, - DnsResolverFlag, - ExperimentalFlag, - NetworkIdFlag, OutputFlag, OutputFormatFlag, - ProfileFlag, - WorkdirFlag, - YesFlag, } from "../command-internal/global-flags.ts"; // The stack backend's `start`/`status`/`stop` are the same commands as `stack @@ -214,15 +208,7 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => Layer.unwrap( Effect.gen(function* () { const explicitOutputFormat = yield* OutputFormatFlag; - const goOutput = yield* OutputFlag; - const profile = yield* ProfileFlag; - const debug = yield* DebugFlag; - const workdir = yield* WorkdirFlag; - const experimental = yield* ExperimentalFlag; - const networkId = yield* NetworkIdFlag; - const yes = yield* YesFlag; - const dnsResolver = yield* DnsResolverFlag; - const createTicket = yield* CreateTicketFlag; + const resourceOutput = yield* OutputFlag; const agent = yield* AgentFlag; const cliArgs = yield* CliArgs; @@ -232,37 +218,20 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => const outputFormat = resolveAgentOutputFormat({ explicitOutputFormat, agentDefaultOutputFormat: agentDefaultOutputFormatFor(options, cliArgs.args), - goOutputFormat: goOutput, + outputFlagFormat: resourceOutput, agentOverride: agent, detectedAgentName: aiTool.name, isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), }); - const globalArgs: string[] = []; - if (Option.isSome(goOutput)) { - globalArgs.push("--output", goOutput.value); - } else if (outputFormat !== "text") { - globalArgs.push("--output", "json"); - } - if (profile !== "supabase") globalArgs.push("--profile", profile); - if (debug) globalArgs.push("--debug"); - if (Option.isSome(workdir)) globalArgs.push("--workdir", workdir.value); - if (experimental) globalArgs.push("--experimental"); - if (Option.isSome(networkId)) globalArgs.push("--network-id", networkId.value); - if (yes) globalArgs.push("--yes"); - if (dnsResolver !== "native") globalArgs.push("--dns-resolver", dnsResolver); - if (createTicket) globalArgs.push("--create-ticket"); - if (agent !== "auto") globalArgs.push("--agent", agent); - // Machine formats keep the text layer's error rendering but suppress the progress // spinner, which would otherwise corrupt the stdout payload. `-o pretty`/`-o table` // (db query's human default) and no `-o` keep the normal text/json layers. - const goFmt = Option.getOrUndefined(goOutput); - const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty" && goFmt !== "table"; - const outputLayer = isGoMachineFormat + const outputLayer = isMachineResourceFormat(resourceOutput) ? quietProgressTextOutputLayer : outputLayerFor(outputFormat); + // A command that provides its own `machineErrorContextLayer` shadows the default cell. return Layer.mergeAll( options.projectState === undefined ? Layer.empty @@ -270,8 +239,9 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => options.stackBackend === undefined ? Layer.empty : stackBackendLayer(options.stackBackend), - outputLayer, - makeGoProxyLayer({ globalArgs, workdir, parentOwnsCapturedSuccessTail: true }), + commandTargetLayer.pipe(Layer.provideMerge(outputLayer)), + machineErrorContextLayer, + Layer.succeed(AiTool, aiTool), ); }), ), diff --git a/apps/cli/src/command-internal/api-url.ts b/apps/cli/src/command-internal/api-url.ts index 8a91bbf436..fad07ecae0 100644 --- a/apps/cli/src/command-internal/api-url.ts +++ b/apps/cli/src/command-internal/api-url.ts @@ -1,4 +1,4 @@ -import { goUrlParse } from "./storage-url.ts"; +import { parseUrl } from "./storage-url.ts"; /** * Derives the local API URL: an explicit `api.external_url` wins, otherwise @@ -34,6 +34,6 @@ export function resolveStudioApiUrl( hostname: string, apiExternalUrl: string, ): string { - const { host } = goUrlParse(rawApiUrl); + const { host } = parseUrl(rawApiUrl); return host === "" || host === hostname ? apiExternalUrl : rawApiUrl; } diff --git a/apps/cli/src/command-internal/pflag-reconcile.ts b/apps/cli/src/command-internal/argv-flag-reconcile.ts similarity index 77% rename from apps/cli/src/command-internal/pflag-reconcile.ts rename to apps/cli/src/command-internal/argv-flag-reconcile.ts index d81324faa0..bfe9fee626 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.ts @@ -1,7 +1,7 @@ import { Data, Effect, FileSystem, Option, Path, Result } from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { lastExplicitLongFlagValue, type PflagArgvScan } from "../shared/cli/cobra-flag-groups.ts"; +import { lastExplicitLongFlagValue, type ArgvFlagScan } from "../shared/cli/flag-groups.ts"; import { ProfileFlag, WorkdirFlag } from "./global-flags.ts"; import { actionability, @@ -11,6 +11,8 @@ import { import { CommandSettings } from "../config/command-settings.service.ts"; import { loadProfile, type LoadedProfile } from "./profile-load.ts"; import { parseStringSliceFlag } from "./string-slice-flag.ts"; +import { classifyRemoteValue, type RemoteValue } from "./target/remote-value.ts"; +import { REMOTE_FLAG_NAME } from "./target/remote-vocabulary.ts"; import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; /** @@ -18,12 +20,12 @@ import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; * validation or API call — the Effect CLI parser never validates this path itself, and can * miss the value entirely when `--workdir` consumed a flag-shaped token. * - * Flows through {@link validatePflagWorkdir}'s inferred Effect error channel; no call site - * imports the class by name. + * Flows through {@link validateArgvWorkdir}'s inferred Effect error channel; no call site + * imports the class by name. The tag is the telemetry fingerprint and must stay stable. * * @public */ -export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ +export class ArgvWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -32,17 +34,16 @@ export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ } /** - * Reconciles an Effect-parsed option flag with pflag's raw-argv semantics: the flag is only - * set when the raw scan says pflag would have set it, using the scan's own value (last - * occurrence wins for a `StringVar`). + * Reconciles an Effect-parsed option flag with the raw-argv scan: the flag is only set when + * the scan shows it set, using the scan's own value (last occurrence wins). * - * The vendored Effect parser refuses to consume a flag-shaped token as a value, while pflag - * consumes it unconditionally — in `--project-ref --metadata-file x.xml --metadata-url u`, - * pflag hands `--metadata-file` to `--project-ref` and never sets `metadata-file`, so acting + * The vendored Effect parser refuses to consume a flag-shaped token as a value, while the raw + * scan consumes it unconditionally — in `--project-ref --metadata-file x.xml --metadata-url u`, + * the scan hands `--metadata-file` to `--project-ref` and never sets `metadata-file`, so acting * on the parsed options there would still read the metadata file unexpectedly. The two agree * on every normal invocation. */ -export function pflagStringValue( +export function argvStringValue( occurrences: ReadonlyMap>, flagName: string, ): Option.Option { @@ -50,8 +51,16 @@ export function pflagStringValue( return values === undefined ? Option.none() : Option.some(values[values.length - 1] ?? ""); } +export function argvRemoteValue( + occurrences: ReadonlyMap>, +): Option.Option { + return Option.flatMap(argvStringValue(occurrences, REMOTE_FLAG_NAME), (raw) => + Option.fromNullishOr(classifyRemoteValue(raw)), + ); +} + /** - * Like `pflagStringValue`, but for CSV-accumulating slice flags: every occurrence is + * Like `argvStringValue`, but for CSV-accumulating slice flags: every occurrence is * CSV-split and accumulated. An absent flag reconciles to `[]` even when the Effect parser * produced values (its tokens were consumed by another flag). * @@ -59,7 +68,7 @@ export function pflagStringValue( * practice, since the Effect parser rejects the same malformed input at parse time before the * handler runs; it just keeps a handler-level disagreement from crashing. */ -export function pflagSliceValue( +export function argvSliceValue( occurrences: ReadonlyMap>, flagName: string, parsedFallback: ReadonlyArray, @@ -82,7 +91,7 @@ export function pflagSliceValue( * default applies, which cannot fail. * * Resolution order: - * - the scan's last `--workdir` occurrence wins — pflag consumes flag-shaped tokens the + * - the scan's last `--workdir` occurrence wins — the scan consumes flag-shaped tokens the * Effect parser refuses (`--workdir --metadata-file` binds `"--metadata-file"`), so the * parsed flag cannot be trusted; * - when the `--workdir` token itself was consumed as another flag's value @@ -91,12 +100,12 @@ export function pflagSliceValue( * - otherwise the Effect-parsed value covers what the anchored scan cannot see: `--workdir` * placed before the command path (`supabase --workdir x sso add …`). */ -export function pflagWorkdirValue( - scan: Pick, +export function argvWorkdirValue( + scan: Pick, parsedWorkdir: Option.Option, envWorkdir: string | undefined, ): Option.Option { - const scanned = pflagStringValue(scan.occurrences, "workdir"); + const scanned = argvStringValue(scan.occurrences, "workdir"); // Same last-wins order as the profile resolver: post-path occurrence, then pre-path // occurrence, then consumed-discard, then the parsed fallback. const prePathValues = scan.prePathOccurrences.get("workdir"); @@ -120,7 +129,7 @@ export function pflagWorkdirValue( } /** - * Validates the workdir {@link pflagWorkdirValue} resolves, aborting before any API call when + * Validates the workdir {@link argvWorkdirValue} resolves, aborting before any API call when * it's missing or not a directory — the config layer only path-resolves the workdir it sees * and never validates this, and can miss the value entirely when `--workdir` consumed a * flag-shaped token. @@ -129,20 +138,20 @@ export function pflagWorkdirValue( * may have resolved the workdir from different sources, but they then issue the identical * request regardless. */ -export const validatePflagWorkdir = Effect.fnUntraced(function* ( - scan: Pick, +export const validateArgvWorkdir = Effect.fnUntraced(function* ( + scan: Pick, ) { // `serviceOption`: absent outside the real CLI tree (handler-level tests // provide argv via `Stdio.layerTest`, not the global flag settings). const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(WorkdirFlag)); const envWorkdir = Option.getOrUndefined((yield* CommandSettings).workdirEnvValue); - const workdir = pflagWorkdirValue(scan, parsedWorkdir, envWorkdir); + const workdir = argvWorkdirValue(scan, parsedWorkdir, envWorkdir); if (Option.isNone(workdir)) { return; } const fs = yield* FileSystem.FileSystem; yield* validateWorkdirIsDirectory(workdir.value, fs).pipe( - Effect.mapError((cause) => new PflagWorkdirError({ message: cause.message })), + Effect.mapError((cause) => new ArgvWorkdirError({ message: cause.message })), ); }); @@ -151,8 +160,8 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( * it falls through to the persisted `~/.supabase/profile` file and then the `supabase` * default. * - * Resolution order mirrors {@link pflagWorkdirValue}: - * - the scan's last `--profile` occurrence wins — pflag consumes flag-shaped tokens the + * Resolution order mirrors {@link argvWorkdirValue}: + * - the scan's last `--profile` occurrence wins — the scan consumes flag-shaped tokens the * Effect parser refuses (`--profile --metadata-url` binds `"--metadata-url"`), and a * scanned occurrence marks the flag changed even when its value is the `supabase` default * or empty; @@ -164,12 +173,12 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( * distinguish an explicit `--profile supabase` from the flag's default, so that value is * treated as unset. */ -export function pflagProfileValue( - scan: Pick, +export function argvProfileValue( + scan: Pick, parsedProfile: Option.Option, envProfile: string | undefined, ): Option.Option { - const scanned = pflagStringValue(scan.occurrences, "profile"); + const scanned = argvStringValue(scan.occurrences, "profile"); // A post-path occurrence wins outright; otherwise a pre-path occurrence // (`--profile A sso add …`) stays effective even when a later profile-shaped token was // consumed as another flag's value. @@ -194,7 +203,7 @@ export function pflagProfileValue( } /** - * Reconciles the profile the Effect config layer resolved with the {@link pflagProfileValue} + * Reconciles the profile the Effect config layer resolved with the {@link argvProfileValue} * semantics, returning the API URL a request must target when they disagree — `Option.none` * means the layer's `CommandSettings.apiUrl` already matches. Loads before the workdir check * and any other flag validation or API request, since it can change which host every @@ -209,8 +218,8 @@ export function pflagProfileValue( * tests provide argv via `Stdio.layerTest`) the flag settings may be absent, and the reconcile * then only acts on what the scan itself shows. */ -export const resolvePflagProfile = Effect.fnUntraced(function* ( - scan: Pick, +export const resolveArgvProfile = Effect.fnUntraced(function* ( + scan: Pick, ) { const parsedRaw = yield* Effect.serviceOption(ProfileFlag); const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase"); @@ -218,7 +227,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( const env = Option.getOrUndefined(settings.profileEnvValue); const envProfile = env !== undefined && env.length > 0 ? env : undefined; - // The explicit token pflag-equivalent semantics resolve, vs. the one the config layer's own + // The explicit token the raw-argv scan resolves, vs. the one the config layer's own // scan resolved (which treats the last raw `--profile` occurrence as explicit even when it // was consumed as another flag's value). When both agree on a non-empty token, the layer // already resolved the right profile. @@ -226,7 +235,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); - const goExplicit = pflagProfileValue(scan, parsedProfile, envProfile); + const argvExplicit = argvProfileValue(scan, parsedProfile, envProfile); const layerExplicit = scanExplicit !== undefined ? Option.some(scanExplicit) @@ -236,10 +245,10 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( ? Option.some(envProfile) : Option.none(); if ( - Option.isSome(goExplicit) && + Option.isSome(argvExplicit) && Option.isSome(layerExplicit) && - goExplicit.value === layerExplicit.value && - goExplicit.value !== "" + argvExplicit.value === layerExplicit.value && + argvExplicit.value !== "" ) { return Option.none(); } @@ -257,8 +266,8 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( .readFileString(path.value.join(settings.supabaseHome, "profile")) .pipe(Effect.option); - const goToken = Option.isSome(goExplicit) - ? goExplicit.value + const argvToken = Option.isSome(argvExplicit) + ? argvExplicit.value : Option.isSome(fileRaw) ? fileRaw.value : "supabase"; @@ -272,14 +281,14 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( }, }); - if (goToken === layerToken && goToken !== "") { + if (argvToken === layerToken && argvToken !== "") { return Option.none(); } - return Option.some(yield* loadProfile(goToken, fs.value)); + return Option.some(yield* loadProfile(argvToken, fs.value)); }); -/** Accepted literal spellings for a pflag boolean flag value (`strconv.ParseBool`). */ -const GO_PARSE_BOOL: ReadonlyMap = new Map([ +/** Accepted literal spellings for a boolean flag value: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False`. */ +const BOOL_LITERALS: ReadonlyMap = new Map([ ["1", true], ["t", true], ["T", true], @@ -295,17 +304,17 @@ const GO_PARSE_BOOL: ReadonlyMap = new Map([ ]); /** - * Like `pflagStringValue`, but for boolean flags: every occurrence is applied in argv order + * Like `argvStringValue`, but for boolean flags: every occurrence is applied in argv order * (a bare occurrence sets `true`; `--flag=value` is checked against the literal set above), * so the last occurrence wins and an absent flag is `false`. An occurrence with an * unrecognized literal fails immediately, before any other flag validation or API call. * * This can't be read off the Effect-parsed boolean: the Effect parser resolves repeated - * flags first-wins (pflag is last-wins) and accepts `yes`/`no`, which the literal set above + * flags first-wins (the scan is last-wins) and accepts `yes`/`no`, which the literal set above * rejects. The scan records a bare occurrence as `"true"` and an inline-empty `--flag=` as * `""`, so both go through the same literal-set check and fail consistently. */ -export function pflagBoolValue( +export function argvBoolValue( occurrences: ReadonlyMap>, flagName: string, ): Result.Result { @@ -315,10 +324,10 @@ export function pflagBoolValue( } let effective = false; for (const raw of values) { - const parsed = GO_PARSE_BOOL.get(raw); + const parsed = BOOL_LITERALS.get(raw); if (parsed === undefined) { return Result.fail( - `invalid argument ${JSON.stringify(raw)} for "--${flagName}" flag: strconv.ParseBool: parsing ${JSON.stringify(raw)}: invalid syntax`, + `invalid argument ${JSON.stringify(raw)} for "--${flagName}" flag: expected a boolean`, ); } effective = parsed; @@ -327,7 +336,7 @@ export function pflagBoolValue( } /** - * Like `pflagStringValue`, but for enum-valued flags: every occurrence must be in `allowed`, + * Like `argvStringValue`, but for enum-valued flags: every occurrence must be in `allowed`, * checked in argv order, and the first invalid one fails immediately — reachable here because * the Effect parser resolves repeats first-wins and never validates later occurrences (e.g. * `--type saml --type bogus` parses). The last occurrence wins; an absent flag is @@ -336,7 +345,7 @@ export function pflagBoolValue( * `flagLabel` names the flag in the error message: `--name` without a shorthand, `-s, --name` * with one. */ -export function pflagEnumValue( +export function argvEnumValue( occurrences: ReadonlyMap>, flagName: string, allowed: ReadonlyArray, diff --git a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts similarity index 62% rename from apps/cli/src/command-internal/pflag-reconcile.unit.test.ts rename to apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts index edd37913ab..99aa404eed 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts @@ -6,13 +6,14 @@ import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "./global-flags.ts"; import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { - pflagBoolValue, - pflagEnumValue, - pflagProfileValue, - pflagWorkdirValue, - resolvePflagProfile, - validatePflagWorkdir, -} from "./pflag-reconcile.ts"; + argvBoolValue, + argvEnumValue, + argvProfileValue, + argvRemoteValue, + argvWorkdirValue, + resolveArgvProfile, + validateArgvWorkdir, +} from "./argv-flag-reconcile.ts"; // Sample enum values for the generic enum-reconciliation helper under test. const NAME_ID_FORMATS = [ @@ -25,76 +26,102 @@ const NAME_ID_FORMATS = [ const occ = (entries: ReadonlyArray]>) => new Map(entries.map(([name, values]) => [name, [...values]])); -describe("pflagBoolValue", () => { - it("is false when the flag never occurs (Go default)", () => { - expect(pflagBoolValue(occ([]), "skip-url-validation")).toEqual(Result.succeed(false)); +describe("argvRemoteValue", () => { + it("is none when --remote never occurs", () => { + expect(argvRemoteValue(occ([]))).toEqual(Option.none()); }); - it('treats a bare occurrence (recorded as pflag\'s NoOptDefVal "true") as true', () => { - expect(pflagBoolValue(occ([["skip-url-validation", ["true"]]]), "skip-url-validation")).toEqual( + it('reads a bare occurrence (recorded as "true") as the linked project', () => { + expect(argvRemoteValue(occ([["remote", ["true"]]]))).toEqual(Option.some({ _tag: "Linked" })); + }); + + it("classifies a ref, a database URL and a branch name like the flag's parser", () => { + expect(argvRemoteValue(occ([["remote", ["abcdefghijklmnopqrst"]]]))).toEqual( + Option.some({ _tag: "Ref", ref: "abcdefghijklmnopqrst" }), + ); + expect(argvRemoteValue(occ([["remote", ["postgresql://u:p@h:5432/db"]]]))).toEqual( + Option.some({ _tag: "DbUrl", url: "postgresql://u:p@h:5432/db" }), + ); + expect(argvRemoteValue(occ([["remote", ["feature-x"]]]))).toEqual( + Option.some({ _tag: "Branch", name: "feature-x" }), + ); + }); + + it("resolves repeats last-wins", () => { + expect(argvRemoteValue(occ([["remote", ["feature-x", "true"]]]))).toEqual( + Option.some({ _tag: "Linked" }), + ); + }); + + it("is none for an off switch", () => { + expect(argvRemoteValue(occ([["remote", ["false"]]]))).toEqual(Option.none()); + }); +}); + +describe("argvBoolValue", () => { + it("is false when the flag never occurs", () => { + expect(argvBoolValue(occ([]), "skip-url-validation")).toEqual(Result.succeed(false)); + }); + + it('treats a bare occurrence (recorded as "true") as true', () => { + expect(argvBoolValue(occ([["skip-url-validation", ["true"]]]), "skip-url-validation")).toEqual( Result.succeed(true), ); }); - it("resolves repeats last-wins, not first-wins (pflag Sets every occurrence)", () => { + it("resolves repeats last-wins, not first-wins", () => { // --skip-url-validation=false --skip-url-validation expect( - pflagBoolValue(occ([["skip-url-validation", ["false", "true"]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["false", "true"]]]), "skip-url-validation"), ).toEqual(Result.succeed(true)); // --skip-url-validation --skip-url-validation=false expect( - pflagBoolValue(occ([["skip-url-validation", ["true", "false"]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["true", "false"]]]), "skip-url-validation"), ).toEqual(Result.succeed(false)); }); - it("fails on an inline-empty occurrence exactly like Go's ParseBool", () => { + it("fails on an inline-empty occurrence", () => { // --skip-url-validation=false --skip-url-validation= expect( - pflagBoolValue(occ([["skip-url-validation", ["false", ""]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["false", ""]]]), "skip-url-validation"), ).toEqual( - Result.fail( - `invalid argument "" for "--skip-url-validation" flag: strconv.ParseBool: parsing "": invalid syntax`, - ), + Result.fail(`invalid argument "" for "--skip-url-validation" flag: expected a boolean`), ); }); - it("accepts exactly Go's strconv.ParseBool literal set", () => { + it("accepts exactly the boolean literal set", () => { for (const raw of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(pflagBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(true)); + expect(argvBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(true)); } for (const raw of ["0", "f", "F", "FALSE", "false", "False"]) { - expect(pflagBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(false)); + expect(argvBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(false)); } }); - it("fails with pflag's byte-exact invalid-argument message on the first bad occurrence", () => { - expect(pflagBoolValue(occ([["skip-url-validation", ["yes"]]]), "skip-url-validation")).toEqual( - Result.fail( - `invalid argument "yes" for "--skip-url-validation" flag: strconv.ParseBool: parsing "yes": invalid syntax`, - ), + it("fails with the invalid-argument message on the first bad occurrence", () => { + expect(argvBoolValue(occ([["skip-url-validation", ["yes"]]]), "skip-url-validation")).toEqual( + Result.fail(`invalid argument "yes" for "--skip-url-validation" flag: expected a boolean`), ); expect( - pflagBoolValue(occ([["skip-url-validation", ["true", "no"]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["true", "no"]]]), "skip-url-validation"), ).toEqual( - Result.fail( - `invalid argument "no" for "--skip-url-validation" flag: strconv.ParseBool: parsing "no": invalid syntax`, - ), + Result.fail(`invalid argument "no" for "--skip-url-validation" flag: expected a boolean`), ); }); }); -describe("pflagEnumValue", () => { +describe("argvEnumValue", () => { it("is none when the flag never occurs", () => { - expect(pflagEnumValue(occ([]), "name-id-format", NAME_ID_FORMATS)).toEqual( + expect(argvEnumValue(occ([]), "name-id-format", NAME_ID_FORMATS)).toEqual( Result.succeed(Option.none()), ); }); - it("resolves repeats last-wins, matching pflag", () => { + it("resolves repeats last-wins", () => { const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; const transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; expect( - pflagEnumValue( + argvEnumValue( occ([["name-id-format", [transient, persistent]]]), "name-id-format", NAME_ID_FORMATS, @@ -102,10 +129,10 @@ describe("pflagEnumValue", () => { ).toEqual(Result.succeed(Option.some(persistent))); }); - it("fails with the Go enum Set message when any occurrence is invalid", () => { + it("fails with the enum message when any occurrence is invalid", () => { const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; expect( - pflagEnumValue( + argvEnumValue( occ([["name-id-format", [persistent, "bogus"]]]), "name-id-format", NAME_ID_FORMATS, @@ -117,14 +144,14 @@ describe("pflagEnumValue", () => { ); }); - it("names the flag with its shorthand when a label is given (pflag errors.go:39-41)", () => { - expect(pflagEnumValue(occ([["type", ["bogus"]]]), "type", ["saml"], "-t, --type")).toEqual( + it("names the flag with its shorthand when a label is given", () => { + expect(argvEnumValue(occ([["type", ["bogus"]]]), "type", ["saml"], "-t, --type")).toEqual( Result.fail(`invalid argument "bogus" for "-t, --type" flag: must be one of [ saml ]`), ); }); }); -describe("pflagWorkdirValue", () => { +describe("argvWorkdirValue", () => { const scan = ( entries: ReadonlyArray]>, consumed: ReadonlyArray = [], @@ -135,10 +162,10 @@ describe("pflagWorkdirValue", () => { prePathOccurrences: occ(prePath), }); - it("resolves pre-path repeats last-wins, like pflag (the parser is first-wins)", () => { + it("resolves pre-path repeats last-wins (the parser is first-wins)", () => { // --workdir /existing --workdir /missing sso add … expect( - pflagWorkdirValue( + argvWorkdirValue( scan([], [], [["workdir", ["/existing", "/missing"]]]), Option.some("/existing"), undefined, @@ -148,17 +175,13 @@ describe("pflagWorkdirValue", () => { it("keeps a pre-path occurrence when the only post-path workdir token was consumed", () => { expect( - pflagWorkdirValue( - scan([], ["workdir"], [["workdir", ["/pre"]]]), - Option.some("/pre"), - "/env", - ), + argvWorkdirValue(scan([], ["workdir"], [["workdir", ["/pre"]]]), Option.some("/pre"), "/env"), ).toEqual(Option.some("/pre")); }); it("post-path occurrences still win over pre-path ones (argv-order last-wins)", () => { expect( - pflagWorkdirValue( + argvWorkdirValue( scan([["workdir", ["/post"]]], [], [["workdir", ["/pre"]]]), Option.some("/pre"), undefined, @@ -166,57 +189,57 @@ describe("pflagWorkdirValue", () => { ).toEqual(Option.some("/post")); }); - it("resolves nothing when no flag, parsed value, or env var is present (Go walks up)", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); + it("resolves nothing when no flag, parsed value, or env var is present", () => { + expect(argvWorkdirValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { // --workdir --metadata-file … expect( - pflagWorkdirValue(scan([["workdir", ["--metadata-file"]]]), Option.none(), "/env"), + argvWorkdirValue(scan([["workdir", ["--metadata-file"]]]), Option.none(), "/env"), ).toEqual(Option.some("--metadata-file")); }); - it("resolves repeats last-wins, matching pflag StringVar", () => { + it("resolves repeats last-wins", () => { expect( - pflagWorkdirValue(scan([["workdir", ["/a", "/b"]]]), Option.some("/a"), undefined), + argvWorkdirValue(scan([["workdir", ["/a", "/b"]]]), Option.some("/a"), undefined), ).toEqual(Option.some("/b")); }); it("falls back to the parsed flag when the anchored scan saw no occurrence (pre-path --workdir)", () => { - expect(pflagWorkdirValue(scan([]), Option.some("/pre-path"), "/env")).toEqual( + expect(argvWorkdirValue(scan([]), Option.some("/pre-path"), "/env")).toEqual( Option.some("/pre-path"), ); }); it("ignores the parsed flag when the --workdir token was consumed by another flag, falling to the env var", () => { // --domains --workdir /x - expect(pflagWorkdirValue(scan([], ["workdir"]), Option.some("/x"), "/env")).toEqual( + expect(argvWorkdirValue(scan([], ["workdir"]), Option.some("/x"), "/env")).toEqual( Option.some("/env"), ); - expect(pflagWorkdirValue(scan([], ["workdir"]), Option.some("/x"), undefined)).toEqual( + expect(argvWorkdirValue(scan([], ["workdir"]), Option.some("/x"), undefined)).toEqual( Option.none(), ); }); it("uses the env var when neither the scan nor the parser saw the flag", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), "/env")).toEqual(Option.some("/env")); + expect(argvWorkdirValue(scan([]), Option.none(), "/env")).toEqual(Option.some("/env")); }); - it("treats a changed-but-empty flag as the walk-up default, shadowing the env var (viper precedence)", () => { + it("treats a changed-but-empty flag as the walk-up default, shadowing the env var", () => { // --workdir= - expect(pflagWorkdirValue(scan([["workdir", [""]]]), Option.none(), "/env")).toEqual( + expect(argvWorkdirValue(scan([["workdir", [""]]]), Option.none(), "/env")).toEqual( Option.none(), ); - expect(pflagWorkdirValue(scan([]), Option.some(""), "/env")).toEqual(Option.none()); + expect(argvWorkdirValue(scan([]), Option.some(""), "/env")).toEqual(Option.none()); }); it("treats an empty env var as unset", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), "")).toEqual(Option.none()); + expect(argvWorkdirValue(scan([]), Option.none(), "")).toEqual(Option.none()); }); }); -describe("pflagProfileValue", () => { +describe("argvProfileValue", () => { const scan = ( entries: ReadonlyArray]>, consumed: ReadonlyArray = [], @@ -230,7 +253,7 @@ describe("pflagProfileValue", () => { it("keeps a pre-path occurrence when the only post-path profile token was consumed", () => { // --profile A sso add --type saml --domains --profile expect( - pflagProfileValue( + argvProfileValue( scan([], ["profile"], [["profile", ["a.yml"]]]), Option.some("a.yml"), "env.yml", @@ -238,9 +261,9 @@ describe("pflagProfileValue", () => { ).toEqual(Option.some("a.yml")); }); - it("resolves pre-path repeats last-wins, like pflag (the parser is first-wins)", () => { + it("resolves pre-path repeats last-wins (the parser is first-wins)", () => { expect( - pflagProfileValue( + argvProfileValue( scan([], [], [["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined, @@ -250,7 +273,7 @@ describe("pflagProfileValue", () => { it("post-path occurrences still win over pre-path ones (argv-order last-wins)", () => { expect( - pflagProfileValue( + argvProfileValue( scan([["profile", ["post.yml"]]], [], [["profile", ["pre.yml"]]]), Option.some("pre.yml"), undefined, @@ -258,37 +281,37 @@ describe("pflagProfileValue", () => { ).toEqual(Option.some("post.yml")); }); - it("resolves nothing when no flag, parsed value, or env var is present (Go falls to the file/default)", () => { - expect(pflagProfileValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); + it("resolves nothing when no flag, parsed value, or env var is present (falls to the file/default)", () => { + expect(argvProfileValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { // --profile --metadata-url … expect( - pflagProfileValue(scan([["profile", ["--metadata-url"]]]), Option.none(), "env.yml"), + argvProfileValue(scan([["profile", ["--metadata-url"]]]), Option.none(), "env.yml"), ).toEqual(Option.some("--metadata-url")); }); - it("resolves repeats last-wins, matching pflag StringVar (the parser is first-wins)", () => { + it("resolves repeats last-wins (the parser is first-wins)", () => { expect( - pflagProfileValue(scan([["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined), + argvProfileValue(scan([["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined), ).toEqual(Option.some("b.yml")); }); - it("keeps an explicit scanned `supabase` — pflag marks it changed, shadowing the env var", () => { - expect(pflagProfileValue(scan([["profile", ["supabase"]]]), Option.none(), "env.yml")).toEqual( + it("keeps an explicit scanned `supabase` — it counts as set, shadowing the env var", () => { + expect(argvProfileValue(scan([["profile", ["supabase"]]]), Option.none(), "env.yml")).toEqual( Option.some("supabase"), ); }); - it("keeps a changed-but-empty occurrence — Go fails LoadProfile on it, never falling to the env", () => { - expect(pflagProfileValue(scan([["profile", [""]]]), Option.none(), "env.yml")).toEqual( + it("keeps a changed-but-empty occurrence — profile loading fails on it, never falling to the env", () => { + expect(argvProfileValue(scan([["profile", [""]]]), Option.none(), "env.yml")).toEqual( Option.some(""), ); }); it("falls back to the parsed flag when the anchored scan saw no occurrence (pre-path --profile)", () => { - expect(pflagProfileValue(scan([]), Option.some("pre.yml"), "env.yml")).toEqual( + expect(argvProfileValue(scan([]), Option.some("pre.yml"), "env.yml")).toEqual( Option.some("pre.yml"), ); }); @@ -296,23 +319,23 @@ describe("pflagProfileValue", () => { it("ignores the parsed flag when the --profile token was consumed by another flag, falling to the env var", () => { // --domains --profile alternate.yml expect( - pflagProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), "env.yml"), + argvProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), "env.yml"), ).toEqual(Option.some("env.yml")); expect( - pflagProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), undefined), + argvProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), undefined), ).toEqual(Option.none()); }); it("uses the env var when neither the scan nor the parser saw the flag", () => { - expect(pflagProfileValue(scan([]), Option.none(), "env.yml")).toEqual(Option.some("env.yml")); + expect(argvProfileValue(scan([]), Option.none(), "env.yml")).toEqual(Option.some("env.yml")); }); it("treats an empty env var as unset", () => { - expect(pflagProfileValue(scan([]), Option.none(), "")).toEqual(Option.none()); + expect(argvProfileValue(scan([]), Option.none(), "")).toEqual(Option.none()); }); }); -describe("resolvePflagProfile", () => { +describe("resolveArgvProfile", () => { const services = (args: ReadonlyArray, homeDir: string, profileEnvValue: string) => Layer.mergeAll( Layer.succeed(ProfileFlag, "supabase"), @@ -329,7 +352,7 @@ describe("resolvePflagProfile", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" }); const profilePath = path.join(dir, "env.yml"); yield* fs.writeFileString( profilePath, @@ -340,7 +363,7 @@ describe("resolvePflagProfile", () => { "project_host: localhost", ].join("\n"), ); - const resolved = yield* resolvePflagProfile({ + const resolved = yield* resolveArgvProfile({ occurrences: new Map(), consumedFlagNames: new Set(["profile"]), prePathOccurrences: new Map(), @@ -364,8 +387,8 @@ describe("resolvePflagProfile", () => { it.effect("returns none when the scan and the layer agree on an explicit supabase", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); - const resolved = yield* resolvePflagProfile({ + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" }); + const resolved = yield* resolveArgvProfile({ occurrences: new Map([["profile", ["supabase"]]]), consumedFlagNames: new Set(), prePathOccurrences: new Map(), @@ -380,9 +403,9 @@ describe("resolvePflagProfile", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" }); yield* fs.writeFileString(path.join(dir, "profile"), "snap\n"); - const error = yield* resolvePflagProfile({ + const error = yield* resolveArgvProfile({ occurrences: new Map(), consumedFlagNames: new Set(), prePathOccurrences: new Map(), @@ -392,12 +415,12 @@ describe("resolvePflagProfile", () => { ), Effect.flip, ); - expect(error.message).toBe(`failed to read profile: Unsupported Config Type ""`); + expect(error.message).toBe(`failed to read profile: unsupported config file type ""`); }).pipe(Effect.provide(BunServices.layer)), ); }); -describe("validatePflagWorkdir", () => { +describe("validateArgvWorkdir", () => { const scan = { occurrences: new Map>(), consumedFlagNames: new Set(), @@ -408,9 +431,9 @@ describe("validatePflagWorkdir", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" }); const missing = path.join(dir, "missing"); - const error = yield* validatePflagWorkdir(scan).pipe( + const error = yield* validateArgvWorkdir(scan).pipe( Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: missing })), Effect.flip, ); @@ -424,8 +447,8 @@ describe("validatePflagWorkdir", () => { it.effect("treats an empty SUPABASE_WORKDIR as unset", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); - yield* validatePflagWorkdir(scan).pipe( + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" }); + yield* validateArgvWorkdir(scan).pipe( Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: "" })), ); }).pipe(Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/command-internal/go-quote.ts b/apps/cli/src/command-internal/byte-quote.ts similarity index 54% rename from apps/cli/src/command-internal/go-quote.ts rename to apps/cli/src/command-internal/byte-quote.ts index dd72c5646b..e444908f14 100644 --- a/apps/cli/src/command-internal/go-quote.ts +++ b/apps/cli/src/command-internal/byte-quote.ts @@ -1,19 +1,16 @@ /** - * `strconv.Quote` (the `%q` verb) over raw UTF-8 bytes, shared by every error message that must - * reproduce a `%q` interpolation byte-for-byte. + * Double-quotes a string over raw UTF-8 bytes for error messages. * - * Operates on bytes, not JS strings: a byte slice can split a multibyte rune (rendered as `\xNN` - * per orphan byte), and escaping decisions are made per decoded rune over those bytes. Callers - * with a whole JS string encode it first; invalid UTF-8 in `process.argv` has already been - * replaced with U+FFFD by then, so byte-identical output for invalid-UTF-8 argv is unattainable at - * that boundary. + * Operates on bytes, not JS strings: a byte slice can split a multibyte code point (rendered as + * `\xNN` per orphan byte). Callers with a whole JS string encode it first; invalid UTF-8 in + * `process.argv` has already been replaced with U+FFFD by then. */ /** - * `utf8.DecodeRune` semantics over a byte slice: returns the code point and byte size at `i`, or - * `cp: -1` with `size: 1` for an invalid byte — exactly the cases rendered as a lone `\xNN`. + * Decodes the UTF-8 code point at `i`, returning its value and byte length, or `cp: -1` with + * `size: 1` for an invalid or truncated sequence. */ -function decodeUtf8Rune( +function decodeUtf8CodePoint( bytes: Uint8Array, i: number, ): { readonly cp: number; readonly size: number } { @@ -47,12 +44,12 @@ function decodeUtf8Rune( return { cp, size: extra + 1 }; } -// Printable runes ≥ 0x80: letters, marks, numbers, punctuation, symbols (ASCII is handled -// explicitly in goQuote). Unicode-table drift between engines only affects which escape a garbage -// rune gets in one error message. -const GO_PRINTABLE_RE = /[\p{L}\p{M}\p{N}\p{P}\p{S}]/u; +// Printable code points ≥ 0x80: letters, marks, numbers, punctuation, symbols (ASCII is handled +// explicitly in quoteBytes). Unicode-table drift between engines only affects which escape a garbage +// code point gets in one error message. +const PRINTABLE_RE = /[\p{L}\p{M}\p{N}\p{P}\p{S}]/u; -const GO_ESCAPES: Readonly> = { +const ESCAPE_SHORTHANDS: Readonly> = { 0x07: "\\a", 0x08: "\\b", 0x0c: "\\f", @@ -63,26 +60,27 @@ const GO_ESCAPES: Readonly> = { }; /** - * `%q` (`strconv.Quote`) over raw UTF-8 bytes. Valid printable runes print literally; - * control/non-printable ones use the `\a…\v` shorthands then `\xNN`/`\uNNNN`/`\UNNNNNNNN`. + * Wraps the bytes in double quotes. Valid printable code points print literally, control + * characters use the `\a \b \f \n \r \t \v` shorthands, each invalid byte becomes `\xNN`, and + * other non-printable code points become `\uNNNN` or `\UNNNNNNNN`. */ -export function goQuote(bytes: Uint8Array): string { +export function quoteBytes(bytes: Uint8Array): string { let out = '"'; for (let i = 0; i < bytes.length;) { - const { cp, size } = decodeUtf8Rune(bytes, i); + const { cp, size } = decodeUtf8CodePoint(bytes, i); if (cp === -1) { out += `\\x${(bytes[i] ?? 0).toString(16).padStart(2, "0")}`; i += 1; continue; } i += size; - const escape = GO_ESCAPES[cp]; + const escape = ESCAPE_SHORTHANDS[cp]; const ch = String.fromCodePoint(cp); if (ch === '"' || ch === "\\") out += `\\${ch}`; else if (escape !== undefined) out += escape; else if (cp >= 0x20 && cp < 0x7f) out += ch; else if (cp < 0x80) out += `\\x${cp.toString(16).padStart(2, "0")}`; - else if (GO_PRINTABLE_RE.test(ch)) out += ch; + else if (PRINTABLE_RE.test(ch)) out += ch; else if (cp < 0x10000) out += `\\u${cp.toString(16).padStart(4, "0")}`; else out += `\\U${cp.toString(16).padStart(8, "0")}`; } diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index b1b6933cf1..3eed599f46 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -1,4 +1,5 @@ import { styleText } from "node:util"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; /** * Structural subset of a write stream that the colour gate inspects. Both @@ -24,7 +25,7 @@ export interface ColorStream { * Node's `FORCE_COLOR` is not honored — only the `CLICOLOR*` pair. */ function supportsColor(stream: ColorStream): boolean { - const env = process.env; + const env = ambientEnvironment(); if ((env["NO_COLOR"] ?? "") !== "") return false; const clicolorForce = env["CLICOLOR_FORCE"] ?? ""; const forced = clicolorForce !== "" && clicolorForce !== "0"; diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index d620c292b2..7cdd957a98 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -9,22 +9,16 @@ import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; import { readPermissions } from "./command-permissions.annotation.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-global-flags.ts"; import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; -import { DB_TARGET_FLAGS } from "../../../tests/helpers/permission-registry.ts"; +import { DB_TARGET_FLAGS, SELECTOR_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readTargetPolicy } from "../target/target-policy.ts"; /** * Whole-command Go delegation, per `docs/go-cli-porting-status.md`. Flag-gated delegation * (`db diff --use-pg-schema`, `functions download --legacy-bundle`) stays mapped with a * `context` note instead. */ -const GO_DELEGATED_PATHS: ReadonlySet = new Set([ - "db branch create", - "db branch delete", - "db branch list", - "db branch switch", - "db remote changes", - "gen keys", -]); +const GO_DELEGATED_PATHS: ReadonlySet = new Set([]); /** * Operations a declaration may reference although the spec gives them no `x-fga-permissions`, @@ -38,6 +32,8 @@ const OPERATIONS_WITHOUT_FGA: ReadonlyMap = new Map = [ { name: "default backend", variant: undefined }, { @@ -57,9 +53,11 @@ function rootGlobalFlagIds(): ReadonlySet { function conditionFlagNames(entries: ReadonlyArray): ReadonlyArray { return entries.flatMap((entry) => - (entry.when ?? []).flatMap((cond: EntryCondition) => - cond.dbTarget === undefined ? [cond.flag] : DB_TARGET_FLAGS, - ), + (entry.when ?? []).flatMap((cond: EntryCondition) => { + if (cond.dbTarget !== undefined) return DB_TARGET_FLAGS; + if (cond.selector !== undefined) return SELECTOR_FLAGS; + return [cond.flag]; + }), ); } @@ -126,18 +124,29 @@ describe("command permission mapping completeness", () => { if (cond.dbTarget !== undefined) { const where = `"${path}"'s \`when\` condition on "${entry.operationId}"`; expect( - permissions.dbTargetDefault, - `${where} checks the database target, but the declaration sets no default — wrap it in withDbTarget`, - ).toBeDefined(); + Option.getOrUndefined(readTargetPolicy(node.command))?.group, + `${where} checks the database target, which only a selectable leaf can choose between`, + ).toBe("selectable"); // A block's condition may name a target the command can't select; a command's own must be reachable. for (const target of cond.source === undefined ? cond.dbTarget : []) { expect( - target === permissions.dbTargetDefault || node.flags.has(target), + target === "local" || node.flags.has(target), `${where} names the "${target}" target, which --${target} can't select and isn't the default`, ).toBe(true); } continue; } + if (cond.selector !== undefined) { + expect( + cond.selector.length, + `"${path}"'s \`when\` condition on "${entry.operationId}" lists no selector shapes`, + ).toBeGreaterThan(0); + expect( + cond.source !== undefined || SELECTOR_FLAGS.some((flag) => node.flags.has(flag)), + `"${path}"'s \`when\` condition on "${entry.operationId}" checks the target selector, but the command accepts no selector flag`, + ).toBe(true); + continue; + } // A condition a building block wrote (`cond.source` set) only has to name a real flag // somewhere in the CLI: a command can include a block without accepting every flag // its conditions mention. On such a command a presence condition is false and an @@ -203,6 +212,19 @@ describe("command permission mapping completeness", () => { } }); + it("composes branch resolution on every leaf that accepts --remote, except the branches that act on a parent project", () => { + for (const [path, node] of leaves) { + const permissions = declarationOf(node); + if (permissions?.status !== "mapped") continue; + if (!node.flags.has("remote") || path.startsWith("branches ")) continue; + if (permissions.noApiEffectFlags.includes("remote")) continue; + expect( + permissions.operations.some((entry) => entry.source === BRANCH_RESOLUTION_SOURCE), + `"${path}" accepts --remote= but does not compose branchResolution`, + ).toBe(true); + } + }); + it("only marks a leaf unmapped when it's on the known go-delegation allowlist", () => { for (const [path, node] of leaves) { const permissions = declarationOf(node); diff --git a/apps/cli/src/command-internal/command-permissions/help-section.integration.test.ts b/apps/cli/src/command-internal/command-permissions/help-section.integration.test.ts index 74c989c158..282aca5eaa 100644 --- a/apps/cli/src/command-internal/command-permissions/help-section.integration.test.ts +++ b/apps/cli/src/command-internal/command-permissions/help-section.integration.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { Console, Effect, Layer, Option } from "effect"; +import { Console, Effect, Layer, Option, Schema } from "effect"; import { CliOutput, Command } from "effect/cli"; import { emptyEnv, fakeConsole, mockOutput } from "../../../tests/helpers/mocks.ts"; import { projectsCommand } from "../../commands/projects/projects.command.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; import { jsonCliOutputFormatter } from "../../shared/output/json-formatter.ts"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; import { @@ -17,6 +18,33 @@ import { YesFlag, } from "../global-flags.ts"; +/* The slice of a command's JSON help that the section assertions read. */ +const HelpJson = Schema.Struct({ + doc: Schema.Unknown, + permissions: Schema.Struct({ + schemes: Schema.Array( + Schema.Struct({ + groups: Schema.Array( + Schema.Struct({ + heading: Schema.String, + rows: Schema.Array( + Schema.Union([ + Schema.Struct({ anyOf: Schema.Array(Schema.Array(Schema.String)) }), + Schema.Struct({ + cases: Schema.Array( + Schema.Struct({ for: Schema.String, allOf: Schema.Array(Schema.String) }), + ), + }), + Schema.Struct({ note: Schema.String }), + ]), + ), + }), + ), + }), + ), + }), +}); + const layerFor = ( args: ReadonlyArray, console: Console.Console, @@ -34,7 +62,7 @@ const layerFor = ( Layer.succeed(WorkdirFlag, Option.none()), Layer.succeed(YesFlag, false), Layer.succeed(DnsResolverFlag, "native"), - mockOutput({ format: "text" }).layer, + commandTargetLayer.pipe(Layer.provideMerge(mockOutput({ format: "text" }).layer)), ); const helpFor = ( @@ -64,8 +92,10 @@ describe("token permissions in command help", () => { " api_gateway_keys_read + api_gateway_keys_secret_read", " Required without --reveal:", " api_gateway_keys_read", - " May be required (without --project-ref):", + " May be required:", " projects_read", + " branching_development_read for a preview branch", + " or branching_production_read for the main branch", " Optional (command still runs without these):", " project_admin_read", ].join("\n"), @@ -77,9 +107,12 @@ describe("token permissions in command help", () => { Effect.gen(function* () { const text = yield* helpFor(["api-keys", "--help"]); const printed = yield* helpFor(["api-keys", "--help"], jsonCliOutputFormatter()); - const json = JSON.parse(printed.replace(/^log:/, "")); + const raw = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + printed.replace(/^log:/, ""), + ); + const json = yield* Schema.decodeUnknownEffect(HelpJson)(raw); - expect(json.permissions).toEqual({ + expect(raw).toHaveProperty("permissions", { status: "mapped", schemes: [ { @@ -99,10 +132,17 @@ describe("token permissions in command help", () => { rows: [{ anyOf: [["api_gateway_keys_read"]] }], }, { - heading: "May be required (without --project-ref)", + heading: "May be required", need: "maybe", - when: [{ all: [{ flag: "project-ref", values: ["unset", "empty"] }] }], - rows: [{ anyOf: [["projects_read"]] }], + rows: [ + { anyOf: [["projects_read"]] }, + { + cases: [ + { allOf: ["branching_development_read"], for: "a preview branch" }, + { allOf: ["branching_production_read"], for: "the main branch" }, + ], + }, + ], }, { heading: "Optional (command still runs without these)", @@ -113,7 +153,9 @@ describe("token permissions in command help", () => { }, ], }); - expect(JSON.stringify(json.doc)).not.toContain("supabase/Command"); + expect( + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(json.doc), + ).not.toContain("supabase/Command"); const block = text.slice(text.indexOf("PERMISSIONS")); for (const scheme of json.permissions.schemes) { @@ -122,12 +164,9 @@ describe("token permissions in command help", () => { for (const row of group.rows) { const shown: ReadonlyArray = "anyOf" in row - ? row.anyOf.map((alternative: ReadonlyArray) => alternative.join(" + ")) + ? row.anyOf.map((alternative) => alternative.join(" + ")) : "cases" in row - ? row.cases.map( - (item: { for: string; allOf: ReadonlyArray }) => - `${item.allOf.join(" + ")} for ${item.for}`, - ) + ? row.cases.map((item) => `${item.allOf.join(" + ")} for ${item.for}`) : [row.note]; for (const text of shown) expect(block).toContain(text); } diff --git a/apps/cli/src/command-internal/command-permissions/help-section.tree.unit.test.ts b/apps/cli/src/command-internal/command-permissions/help-section.tree.unit.test.ts index b48f858f26..a506950c1f 100644 --- a/apps/cli/src/command-internal/command-permissions/help-section.tree.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/help-section.tree.unit.test.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { Context, Effect, Exit, Option } from "effect"; import type { Command } from "effect/cli"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; @@ -69,39 +69,41 @@ describe("permission help section across the command tree", () => { ); }; - it("gives a flag a default only when parsing no arguments yields that value", async () => { - for (const [path, node] of leaves) { - const declared = Option.getOrUndefined(readPermissions(node.command)); - if (declared?.status !== "mapped") continue; - const params = paramsFor(path); - // Help rebuilds a parent group's flags without defaults, so only these carry one. - const known = new Map( - [...rootFlags, ...permissionFlagsOf(ownParams(node.command))].map((flag) => [ - flag.name, - flag, - ]), - ); - const tested = new Set( - declared.operations.flatMap((entry) => - (entry.when ?? []).flatMap((condition) => - condition.oneOf === undefined ? [] : [condition.flag], + it.effect("gives a flag a default only when parsing no arguments yields that value", () => + Effect.gen(function* () { + for (const [path, node] of leaves) { + const declared = Option.getOrUndefined(readPermissions(node.command)); + if (declared?.status !== "mapped") continue; + const params = paramsFor(path); + // Help rebuilds a parent group's flags without defaults, so only these carry one. + const known = new Map( + [...rootFlags, ...permissionFlagsOf(ownParams(node.command))].map((flag) => [ + flag.name, + flag, + ]), + ); + const tested = new Set( + declared.operations.flatMap((entry) => + (entry.when ?? []).flatMap((condition) => + condition.oneOf === undefined ? [] : [condition.flag], + ), ), - ), - ); - for (const flag of tested) { - const param = params.get(flag); - if (flag === undefined || param === undefined) continue; - const exit = await Effect.runPromiseExit( - param.parse({ flags: {}, arguments: [] }).pipe(Effect.provide(BunServices.layer)), ); - const parsed = Exit.isSuccess(exit) ? exit.value[1] : undefined; - expect( - known.get(flag)?.default, - `"${path}" --${flag} default differs from parsing no arguments`, - ).toBe(typeof parsed === "string" ? parsed : undefined); + for (const flag of tested) { + const param = params.get(flag); + if (flag === undefined || param === undefined) continue; + const exit = yield* param + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer), Effect.exit); + const parsed = Exit.isSuccess(exit) ? exit.value[1] : undefined; + expect( + known.get(flag)?.default, + `"${path}" --${flag} default differs from parsing no arguments`, + ).toBe(typeof parsed === "string" ? parsed : undefined); + } } - } - }); + }), + ); it("never tests the presence of a text flag that has a default", () => { for (const [path, node] of leaves) { @@ -120,28 +122,30 @@ describe("permission help section across the command tree", () => { } }); - it("never turns on by default a switch a condition tests", async () => { - for (const [path, node] of leaves) { - const declared = Option.getOrUndefined(readPermissions(node.command)); - if (declared?.status !== "mapped") continue; - const params = paramsFor(path); - const tested = new Set( - declared.operations.flatMap((entry) => - (entry.when ?? []).flatMap((condition) => - condition.is === undefined ? [] : [condition.flag], + it.effect("never turns on by default a switch a condition tests", () => + Effect.gen(function* () { + for (const [path, node] of leaves) { + const declared = Option.getOrUndefined(readPermissions(node.command)); + if (declared?.status !== "mapped") continue; + const params = paramsFor(path); + const tested = new Set( + declared.operations.flatMap((entry) => + (entry.when ?? []).flatMap((condition) => + condition.is === undefined ? [] : [condition.flag], + ), ), - ), - ); - for (const flag of tested) { - const param = params.get(flag); - if (param === undefined || catalogFor(path).get(flag)?.hidden !== false) continue; - const [, value] = await Effect.runPromise( - param.parse({ flags: {}, arguments: [] }).pipe(Effect.provide(BunServices.layer)), ); - expect(value, `"${path}" --${flag} defaults to on`).not.toBe(true); + for (const flag of tested) { + const param = params.get(flag); + if (param === undefined || catalogFor(path).get(flag)?.hidden !== false) continue; + const [, value] = yield* param + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)); + expect(value, `"${path}" --${flag} defaults to on`).not.toBe(true); + } } - } - }); + }), + ); it("records every flag a command accepts when it declares its permissions", () => { for (const [path, node] of leaves) { diff --git a/apps/cli/src/command-internal/command-permissions/help-section.ts b/apps/cli/src/command-internal/command-permissions/help-section.ts index 85a3e9c4cd..3d0f57af7f 100644 --- a/apps/cli/src/command-internal/command-permissions/help-section.ts +++ b/apps/cli/src/command-internal/command-permissions/help-section.ts @@ -10,9 +10,7 @@ import { conditionText, joinAnd, joinOr, - onlyLinked, simplify, - TARGET_VARIABLE, termOf, withoutVariables, } from "./permission-conditions.ts"; @@ -23,7 +21,7 @@ import type { ConditionData, Term, } from "./permission-conditions.ts"; -import { CommandFlagsAnnotation, flagCatalogFor } from "./permission-flags.ts"; +import { flagCatalogFor } from "./permission-flags.ts"; import type { FlagCatalog } from "./permission-flags.ts"; import { permissionSchemes } from "./permission-schemes.ts"; import type { Alternatives, PermissionScheme, Requirement } from "./permission-schemes.ts"; @@ -214,11 +212,6 @@ function schemeSection( }; } -function notNeededWithOtherTargets(catalog: FlagCatalog): string { - const flags = ["local", "db-url"].filter((name) => catalog.get(name)?.hidden === false); - return flags.length === 0 ? "" : `Not needed with ${joinOr(flags.map((flag) => `--${flag}`))}.`; -} - /** * What help shows for a command's declared permissions, or `undefined` when the command calls no * API operation. Each registered scheme is resolved on its own. @@ -229,7 +222,7 @@ export function resolvePermissionSection( schemes: ReadonlyArray = permissionSchemes, ): PermissionSection | undefined { if (permissions.status === "unmapped") return { status: "unmapped" }; - const context = conditionContext(permissions.operations, catalog, permissions.dbTargetDefault); + const context = conditionContext(permissions.operations, catalog); const reachable = permissions.operations.flatMap((entry): ReadonlyArray => { const reduced = termOf(entry.when, context); return reduced === undefined @@ -238,30 +231,21 @@ export function resolvePermissionSection( }); if (reachable.length === 0) return undefined; - const { dbTargetDefault } = permissions; - const liftTarget = dbTargetDefault === "linked" && onlyLinked(reachable.map(({ term }) => term)); - const withoutLifted = liftTarget - ? reachable.map((item) => ({ ...item, term: withoutVariables(item.term, [TARGET_VARIABLE]) })) - : reachable; const common = commonConditions( - withoutLifted.map(({ term }) => term), + reachable.map(({ term }) => term), context, ); - const terms = withoutLifted.map((item) => ({ + const terms = reachable.map((item) => ({ ...item, term: withoutVariables(item.term, common.variables), })); const note = [ - ...(liftTarget ? [notNeededWithOtherTargets(catalog)] : []), ...(common.positive.length > 0 ? [`Only needed with ${joinAnd(common.positive)}.`] : []), ...(common.negative.length > 0 ? [`Not needed with ${joinOr(common.negative)}.`] : []), ] .filter((sentence) => sentence !== "") .join(" "); - const when: ReadonlyArray = [ - ...(liftTarget ? [{ target: ["linked"] }] : []), - ...common.conditions, - ]; + const when: ReadonlyArray = common.conditions; return { status: "mapped", ...(note === "" ? {} : { note }), @@ -321,14 +305,3 @@ export function permissionSectionForHelp(doc: HelpDoc.HelpDoc): PermissionSectio (declared) => Option.fromNullishOr(resolvePermissionSection(declared, flagCatalogFor(doc))), ).pipe(Option.getOrUndefined); } - -/** The help document without the annotations that hold a command's raw permission declaration. */ -export function withoutPermissionAnnotations(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { - return { - ...doc, - annotations: Context.omit( - CommandPermissionsAnnotation, - CommandFlagsAnnotation, - )(doc.annotations), - }; -} diff --git a/apps/cli/src/command-internal/command-permissions/help-section.unit.test.ts b/apps/cli/src/command-internal/command-permissions/help-section.unit.test.ts index 1374151e27..a2f627fcfe 100644 --- a/apps/cli/src/command-internal/command-permissions/help-section.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/help-section.unit.test.ts @@ -18,7 +18,6 @@ const projectRef = Flag.String("project-ref").pipe(Flag.optional); interface Declaration { readonly operations: ReadonlyArray; - readonly dbTargetDefault?: "local" | "linked"; } function render( @@ -570,55 +569,23 @@ describe("permission help section", () => { ); }); - it("says it is not needed with the other targets when the default is the linked project", () => { - const text = render( - { - dbTargetDefault: "linked", - operations: [ - { - operationId: "v1-list-all-projects", - kind: "required", - when: [{ dbTarget: ["linked"] }], - }, - ], - }, - catalogOf( - switchFlag("local"), - Flag.String("db-url").pipe(Flag.optional), - switchFlag("linked"), - ), - ); + const hiddenSwitch = (name: string) => switchFlag(name).pipe(Flag.withHidden); + const linkedOnly = { + operationId: "v1-list-all-projects", + kind: "required", + when: [{ dbTarget: ["linked"] }], + } as const satisfies OperationEntry; - expect(text).toBe( - lines( - "PERMISSIONS", - " Not needed with --local or --db-url.", - fga, - " Required:", - " projects_read", - ), - ); - }); - - it("says it is only needed with --linked when the default is the local database", () => { + it("says a linked-only call is only needed with --remote", () => { const text = render( - { - dbTargetDefault: "local", - operations: [ - { - operationId: "v1-list-all-projects", - kind: "required", - when: [{ dbTarget: ["linked"] }], - }, - ], - }, - catalogOf(switchFlag("local"), switchFlag("linked")), + { operations: [linkedOnly] }, + catalogOf(hiddenSwitch("local"), hiddenSwitch("linked")), ); expect(text).toBe( lines( "PERMISSIONS", - " Only needed with --linked.", + " Only needed with --remote.", fga, " Required:", " projects_read", @@ -628,18 +595,8 @@ describe("permission help section", () => { it("keeps the target in the heading when some entries do not depend on it", () => { const text = render( - { - dbTargetDefault: "linked", - operations: [ - { - operationId: "v1-list-all-projects", - kind: "required", - when: [{ dbTarget: ["linked"] }], - }, - { operationId: "v1-get-project", kind: "required" }, - ], - }, - catalogOf(switchFlag("local"), switchFlag("linked")), + { operations: [linkedOnly, { operationId: "v1-get-project", kind: "required" }] }, + catalogOf(hiddenSwitch("local"), hiddenSwitch("linked")), ); expect(text).toBe( @@ -648,25 +605,30 @@ describe("permission help section", () => { fga, " Required:", " project_admin_read", - " Required (remote only):", + " Required with --remote:", " projects_read", ), ); }); it("drops a target condition the command cannot vary", () => { + const text = render({ operations: [linkedOnly] }); + + expect(text).toBe(lines("PERMISSIONS", fga, " Required:", " projects_read")); + }); + + it("hedges an entry that depends on how --remote is given", () => { const text = render({ - dbTargetDefault: "linked", operations: [ { operationId: "v1-list-all-projects", kind: "required", - when: [{ dbTarget: ["linked"] }], + when: [{ selector: ["none", "bare"] }], }, ], }); - expect(text).toBe(lines("PERMISSIONS", fga, " Required:", " projects_read")); + expect(text).toBe(lines("PERMISSIONS", fga, " May be required:", " projects_read")); }); }); @@ -1105,10 +1067,9 @@ describe("permission help section", () => { expect(groups.map(([, when]) => when)).toEqual([undefined, undefined]); }); - it("names the linked project as the shared target when it is the default", () => { + it("names the linked project as the shared target when every entry needs it", () => { const { shared } = whenOf( { - dbTargetDefault: "linked", operations: [ { operationId: "v1-list-all-projects", @@ -1148,7 +1109,6 @@ describe("permission help section", () => { it("treats naming a target flag as selecting that target", () => { const { groups } = whenOf( { - dbTargetDefault: "local", operations: [ { operationId: "v1-list-all-projects", kind: "required" }, { @@ -1168,14 +1128,13 @@ describe("permission help section", () => { expect(groups).toEqual([ ["Required", undefined], - ["Required with --linked", [{ all: [{ target: ["linked"] }] }]], + ["Required with --remote", [{ all: [{ target: ["linked"] }] }]], ]); }); it("names a target in a group's condition when not every entry depends on it", () => { const { shared, groups } = whenOf( { - dbTargetDefault: "local", operations: [ { operationId: "v1-list-all-projects", @@ -1189,7 +1148,7 @@ describe("permission help section", () => { ); expect(shared).toBeUndefined(); - expect(groups[1]).toEqual(["Required with --linked", [{ all: [{ target: ["linked"] }] }]]); + expect(groups[1]).toEqual(["Required with --remote", [{ all: [{ target: ["linked"] }] }]]); }); }); diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 8f063ab9ea..683e166e6b 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -1,46 +1,31 @@ /** Serializable description of the Management API operations a command may call, keyed by the spec's `operationId`. */ -/** - * Whether a command's useful result depends on the call. `best-effort` means a 403 on the call - * still leaves the command's useful result; anything else is `required`. - */ +/** `best-effort` keeps the command's result on a 403; anything else is `required`. */ type PermissionKind = "required" | "best-effort"; interface FlagConditionBase { readonly flag: string; - /** - * The building block that wrote this condition (set by {@link block}). A block's condition may - * name a flag the including command lacks; a condition without `source`, including one added - * by {@link withCondition}, must name a flag of the command it's declared on. - */ + /** The block that wrote this condition; absent, the flag must belong to the declaring command. */ readonly source?: string; readonly dbTarget?: never; + readonly selector?: never; } -/** - * The flag must be present (or absent, when `present: false`) for an entry to apply. A flag given - * an empty value counts as absent. - */ +/** The flag must be present (or absent, with `present: false`); an empty value counts as absent. */ interface PresenceCondition extends FlagConditionBase { readonly present?: boolean; readonly oneOf?: never; readonly is?: never; } -/** - * The switch's effective value, default included, must equal `is` for an entry to apply. A switch - * the including command lacks counts as `false`. - */ +/** The switch's effective value, default included, must equal `is`; a missing switch is `false`. */ interface SwitchCondition extends FlagConditionBase { readonly is: boolean; readonly present?: never; readonly oneOf?: never; } -/** - * The flag's value must be one of `oneOf` for an entry to apply. For a flag with a default, the - * effective value counts, so a run that omits `--lang` still matches `oneOf: ["typescript"]`. - */ +/** The flag's effective value, default included, must be one of `oneOf`. */ interface ValueCondition extends FlagConditionBase { readonly oneOf: ReadonlyArray; readonly present?: never; @@ -49,27 +34,38 @@ interface ValueCondition extends FlagConditionBase { type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; -/** - * 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. - */ +/** The target a command talks to: the local stack, a database URL, or a hosted project. */ export type DbTarget = "local" | "db-url" | "linked"; /** - * The run's database target must be one of `dbTarget` for an entry to apply. The target is - * `--db-url` if given, else `--local`, else `--linked`, else the default set with - * {@link withDbTarget}. As with {@link FlagCondition}, `source` marks a building block's condition. + * The run's database target must be one of `dbTarget`: `--db-url` or `--remote=`, else + * `--local`, else a linked selector, else the local stack. */ interface DbTargetCondition { readonly dbTarget: ReadonlyArray; readonly source?: string; + readonly selector?: never; + readonly flag?: never; + readonly present?: never; + readonly is?: never; + readonly oneOf?: never; +} + +/** The selector's shape after folding in the deprecated aliases; `bare` carries no value. */ +export type SelectorShape = "none" | "bare" | "ref" | "branch" | "uuid" | "url"; + +/** Holds when the selector's shape is one of `selector`, whichever flag spelled it. */ +interface SelectorCondition { + readonly selector: ReadonlyArray; + readonly source?: string; + readonly dbTarget?: never; readonly flag?: never; readonly present?: never; readonly is?: never; readonly oneOf?: never; } -export type EntryCondition = FlagCondition | DbTargetCondition; +export type EntryCondition = FlagCondition | DbTargetCondition | SelectorCondition; /** The parts of a request a permission can depend on, string-encoded like the request on the wire. */ export interface RequestDetails { @@ -96,8 +92,6 @@ export interface PermissionFragment { readonly operations: ReadonlyArray; /** Flags this fragment contributes that never change which operations it calls. */ readonly noApiEffectFlags?: ReadonlyArray; - /** Which database the command targets when no target flag is given; see {@link withDbTarget}. */ - readonly dbTargetDefault?: "local" | "linked"; } export type CommandPermissions = @@ -105,17 +99,10 @@ export type CommandPermissions = readonly status: "mapped"; readonly operations: ReadonlyArray; readonly noApiEffectFlags: ReadonlyArray; - readonly dbTargetDefault?: "local" | "linked"; } | { readonly status: "unmapped"; readonly reason: "go-delegated" }; -/** - * Builds a named building block for Management API calls a shared module makes on behalf of its - * callers, tagging every entry and each of its conditions with `source` (see - * {@link FlagConditionBase.source}). Declare it next to the module that makes the calls; a command - * includes the blocks its handler reaches with {@link compose}, adding its own condition (e.g. - * `--linked`) with {@link withCondition} where the module is only used on that path. - */ +/** A shared module's calls, tagged with `source`; commands include it via {@link compose}. */ export function block( source: string, operations: ReadonlyArray>, @@ -135,25 +122,13 @@ export function block( /** Merges fragments' operations and `noApiEffectFlags` into one, preserving entry order. */ export function compose(...fragments: ReadonlyArray): PermissionFragment { - const defaults = new Set(fragments.flatMap((fragment) => fragment.dbTargetDefault ?? [])); - if (defaults.size > 1) { - throw new Error( - `compose: fragments disagree on the default database target (${[...defaults]})`, - ); - } - const [dbTargetDefault] = defaults; return { operations: fragments.flatMap((fragment) => fragment.operations), noApiEffectFlags: fragments.flatMap((fragment) => fragment.noApiEffectFlags ?? []), - ...(dbTargetDefault === undefined ? {} : { dbTargetDefault }), }; } -/** - * Adds `condition` to every operation entry in `fragment`, ahead of each entry's own `when`. 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. - */ +/** Prepends `condition` to each `when`; it must name a flag or target the command declares. */ export function withCondition( fragment: PermissionFragment, condition: EntryCondition, @@ -167,10 +142,7 @@ 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. - */ +/** Adds `prerequisite` to each entry's `context`, for a non-flag condition like a prompt choice. */ export function withContext( fragment: PermissionFragment, prerequisite: string, @@ -185,10 +157,7 @@ export function withContext( } /** - * 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 - * block treats as required). - * + * Overrides every entry's `kind` with the one the including command handles it with. * @public */ export function withKind(fragment: PermissionFragment, kind: PermissionKind): PermissionFragment { @@ -198,27 +167,11 @@ export function withKind(fragment: PermissionFragment, kind: PermissionKind): Pe }; } -/** - * Sets the database a command targets when no `--db-url`, `--local` or `--linked` flag is given, - * for entries with a {@link DbTargetCondition}. - * - * @public - */ -export function withDbTarget( - fragment: PermissionFragment, - options: { readonly default: "local" | "linked" }, -): PermissionFragment { - return { ...fragment, dbTargetDefault: options.default }; -} - /** Builds a `"mapped"` {@link CommandPermissions} from a fragment, e.g. the result of {@link compose}. */ export function mapped(fragment: PermissionFragment): CommandPermissions { return { status: "mapped", operations: fragment.operations, noApiEffectFlags: fragment.noApiEffectFlags ?? [], - ...(fragment.dbTargetDefault === undefined - ? {} - : { dbTargetDefault: fragment.dbTargetDefault }), }; } diff --git a/apps/cli/src/command-internal/command-permissions/permission-conditions.ts b/apps/cli/src/command-internal/command-permissions/permission-conditions.ts index 020dd48408..6a2b78b800 100644 --- a/apps/cli/src/command-internal/command-permissions/permission-conditions.ts +++ b/apps/cli/src/command-internal/command-permissions/permission-conditions.ts @@ -8,6 +8,13 @@ const TARGET = "\u0000target"; const TARGET_FLAGS = ["db-url", "local", "linked"] as const satisfies ReadonlyArray; +/** How help names each target; the target flags themselves are hidden aliases of `--remote`. */ +const TARGET_LABELS: Readonly> = { + local: "the local stack", + linked: "--remote", + "db-url": "--remote=", +}; + const isLiteral = (value: string) => ![ABSENT, EMPTY, SET].includes(value); /** A literal value is a case of "given any value", so a set holding both keeps only `SET`. */ @@ -32,24 +39,18 @@ export type Condition = ReadonlyArray; export interface ConditionContext { readonly catalog: FlagCatalog; - /** The target a run uses when no target flag is given; set with `withDbTarget`. */ - readonly targetDefault: "local" | "linked" | undefined; /** The values a variable can take; `undefined` for a variable the command does not have. */ readonly domainOf: (variable: string) => ReadonlyArray | undefined; /** The database targets a run can select. */ - readonly targets: ReadonlyArray; + readonly targets: ReadonlyArray; } export function conditionContext( entries: ReadonlyArray, catalog: FlagCatalog, - targetDefault: "local" | "linked" | undefined, ): ConditionContext { const conditions = entries.flatMap((entry) => entry.when ?? []); - const targets = [ - ...TARGET_FLAGS.filter((flag) => catalog.has(flag)), - ...(targetDefault !== undefined && !catalog.has(targetDefault) ? [targetDefault] : []), - ]; + const targets = TARGET_FLAGS.filter((flag) => catalog.has(flag)); const domainOf = (variable: string): ReadonlyArray | undefined => { if (variable === TARGET) return targets; const flag = catalog.get(variable); @@ -63,7 +64,7 @@ export function conditionContext( } return [ABSENT, EMPTY, SET]; }; - return { catalog, targetDefault, domainOf, targets }; + return { catalog, domainOf, targets }; } type Constraint = @@ -158,6 +159,11 @@ export function termOf( const term = new Map>(); let widened = false; for (const condition of conditions ?? []) { + // Help does not spell out how `--remote` was given, so a selector condition only hedges. + if (condition.selector !== undefined) { + widened = true; + continue; + } const flag = condition.flag === undefined ? undefined : context.catalog.get(condition.flag); const constraint = condition.dbTarget !== undefined @@ -245,23 +251,10 @@ export function conditionImplies(strong: Condition, weak: Condition): boolean { return strong.every((term) => weak.some((other) => implies(term, other))); } -export const TARGET_VARIABLE = TARGET; - export function withoutVariables(term: Term, variables: ReadonlyArray): Term { return new Map([...term].filter(([variable]) => !variables.includes(variable))); } -/** Whether every term restricts the target to the linked project alone. */ -export function onlyLinked(terms: ReadonlyArray): boolean { - return ( - terms.length > 0 && - terms.every((term) => { - const values = term.get(TARGET); - return values !== undefined && values.size === 1 && values.has("linked"); - }) - ); -} - export const joinAnd = (items: ReadonlyArray) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`; @@ -280,11 +273,8 @@ function textOfVariable( context: ConditionContext, ): Partial { if (variable === TARGET) { - if (values.size === 1 && values.has("linked")) { - return context.targetDefault === "linked" ? { remote: true } : { positive: ["--linked"] }; - } const named = context.targets.filter((target) => values.has(target)); - return { positive: [joinOr(named.map((target) => `--${target}`))] }; + return { positive: [joinOr(named.map((target) => TARGET_LABELS[target]))] }; } const flag = context.catalog.get(variable); const name = `--${variable}`; diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 5586e782e7..6588290991 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -9,29 +9,33 @@ import { } from "@supabase/config/effect"; import { applyConfigEdits, - decodeCliConfigDocumentForValidationEffect, writeCliConfigDocumentText, type ConfigEdit, type ConfigEditRefusalReason, - type DecodeCliConfigDocumentForValidationEffectOptions, + parseCliConfigDocument, } from "@supabase/config/internal"; import type { ConfigChange } from "@supabase/config"; import { operationDefinitions } from "@supabase/api/effect"; -import { Effect, FileSystem, Result, Schema, SchemaIssue } from "effect"; +import { Effect, FileSystem, Option, Result, Schema, SchemaIssue } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { Output } from "../shared/output/output.service.ts"; +import { + decodeCliConfigDocumentForValidation, + type CliConfigValidationOptions, +} from "../config/cli-config-validation.ts"; import { block } from "./command-permissions/model.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; -import type { ConfigTarget } from "./project-target.ts"; +import type { LinkedTarget } from "./target/resolve-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; import { configDeepSetAtPath, configIsRecord, configPathKey, } from "../commands/config/config.paths.ts"; -import { loadLocalConfig, relativeConfigPath } from "../commands/config/config.load.ts"; +import { loadDeclaredFileConfig, relativeConfigPath } from "../commands/config/config.load.ts"; import { CONFIG_CLASS_LABELS, configApiScope, @@ -89,7 +93,7 @@ import { */ export interface ConfigPullPlanRequest { - readonly target: ConfigTarget; + readonly target: Pick; readonly remoteLabel: string | undefined; readonly source: ConfigPullSource; } @@ -299,7 +303,7 @@ function configPullChangeRelativeValue( } /** - * Converts a failed {@link decodeCliConfigDocumentForValidationEffect} attempt's + * Converts a failed {@link decodeCliConfigDocumentForValidation} attempt's * `SchemaIssue` paths into `ConfigChange.path`-relative segments. `isLabelPrefixed` strips * one leading segment for the raw/unmerged projection of a remote destination, whose issue * paths start with the `remotes` map's own key (the label) rather than already being @@ -373,16 +377,16 @@ function configPullFamiliesForChangePaths( } /** - * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only its own + * Runs {@link decodeCliConfigDocumentForValidation}, capturing only its own * `CliConfigParseError` failure into a `Result`. A genuinely malformed `.env`/`.env.local`, * or a filesystem failure reading one, is not a decode-attribution failure, so those - * propagate uncaught, matching how the real `loadCliConfig` call already handles them. + * propagate uncaught, matching how the resolved config load handles them. */ function decodeConfigPullValidation( document: Record, - options: DecodeCliConfigDocumentForValidationEffectOptions, + options: CliConfigValidationOptions, ) { - return decodeCliConfigDocumentForValidationEffect(document, options).pipe( + return decodeCliConfigDocumentForValidation(document, options).pipe( Effect.map(Result.succeed), Effect.catchTag("CliConfigParseError", (cause) => Effect.succeed(Result.fail(cause))), ); @@ -390,7 +394,7 @@ function decodeConfigPullValidation( /** * The change-path keys ({@link configPathKey}) that already fail - * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` as it sits on disk + * {@link decodeCliConfigDocumentForValidation} in `rawDocument` as it sits on disk * right now, before this pull's own writes are projected. {@link validateConfigPullPlan} * excludes these from the families it forms — pulling should never attribute, drop, or fail * over a pre-existing problem; it only needs to leave the file no worse than it was. Runs @@ -407,7 +411,6 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( const rawDecoded = yield* decodeConfigPullValidation(input.rawDocument, { path: input.configPath, format: input.format, - goViperCompat: true, }); if (Result.isFailure(rawDecoded)) { for (const path of configPullSchemaIssueChangePaths( @@ -421,7 +424,6 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( const mergedDecoded = yield* decodeConfigPullValidation(input.rawDocument, { path: input.configPath, format: input.format, - goViperCompat: true, remoteName: input.destination.label, }); if (Result.isFailure(mergedDecoded)) { @@ -439,7 +441,7 @@ const CONFIG_PULL_VALIDATION_ROUND_CAP = 4; /** * `config pull`'s schema-validation gate: decodes the projected final document the way the - * next `loadCliConfig` call will (including a `[remotes.*]` destination's `remoteName`-merged + * next config load will (including a `[remotes.*]` destination's `remoteName`-merged * projection, since a block can pass the raw check yet still fail once selected), and never * writes a file the CLI itself couldn't load. A failure already present in * {@link configPullPreExistingFailingChangePathKeys} is ignored; a new one drops its @@ -472,14 +474,12 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { input.projectRef, ); const rawDecoded = yield* decodeConfigPullValidation(document, { - goViperCompat: true, path: input.configPath, format: input.format, }); const mergedDecoded = destination.kind === "remote" ? yield* decodeConfigPullValidation(document, { - goViperCompat: true, path: input.configPath, format: input.format, remoteName: destination.label, @@ -534,25 +534,7 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { }); }); -/** Builds the file-load helpers for one `cliSettings.workdir`, narrowed to `workdir` and - * `explicitWorkdir` since that's all `loadLocalConfig` needs; only this family's own tagged - * error class is local. */ -function makeConfigLoader(cliSettings: { - readonly workdir: string; - readonly explicitWorkdir: boolean; -}) { - const toRelativeConfigPath = (path: string): string => - relativeConfigPath(cliSettings.workdir, path); - - const loadConfig = (projectRef: string | undefined) => - loadLocalConfig( - cliSettings, - projectRef, - (message) => new ConfigPullLoadConfigError({ message }), - ); - - return { toRelativeConfigPath, loadConfig }; -} +const makeLoadError = (message: string) => new ConfigPullLoadConfigError({ message }); /** * The paired base config load and its exact on-disk text, produced only by @@ -574,22 +556,26 @@ export interface ConfigPullSource { */ export const openConfigPullSource = Effect.fn("ConfigPull.openSource")(function* () { const cliSettings = yield* CommandSettings; - const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); - const loaded = yield* loadConfig(undefined); + const { loaded } = yield* loadDeclaredFileConfig(cliSettings, Option.none(), makeLoadError); if (loaded.rawText === undefined) { // The loader guarantees `rawText` for any file it parsed off disk; treat this like a // concurrent edit rather than re-reading, which would reopen the race this baseline // exists to close. return yield* new ConfigPullFileChangedError({ - message: `${toRelativeConfigPath(loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, + message: `${relativeConfigPath(cliSettings.workdir, loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, }); } return { loaded, text: loaded.rawText }; }); +function configPullSourceDocument(source: ConfigPullSource): Readonly> { + const parsed = parseCliConfigDocument(source.text, source.loaded.format); + return configIsRecord(parsed) ? parsed : {}; +} + /** `planConfigPullRun`'s remote config read, shared by `config pull` and `pull`. */ export const configPullRun = block("config-pull-run", [ { operationId: "v2-get-project-config", kind: "required" }, @@ -610,7 +596,6 @@ export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const { ref, branch } = request.target; - const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); const branchLabelCandidate = branch !== undefined && !BRANCH_UUID_PATTERN.test(branch) ? branch : undefined; @@ -642,14 +627,14 @@ export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( // A brand-new block has nothing to overlay yet. let loaded = request.source.loaded; if (destination.kind === "remote" && !destination.created) { - loaded = yield* loadConfig(ref); + ({ loaded } = yield* loadDeclaredFileConfig(cliSettings, Option.some(ref), makeLoadError)); } const context: ConfigPullContext = { projectRef: ref, branch, configSchema: loaded.schemaRef ?? CLI_CONFIG_SCHEMA_URL, - configPath: toRelativeConfigPath(loaded.path), + configPath: relativeConfigPath(cliSettings.workdir, loaded.path), format: loaded.format, appliedRemote: loaded.appliedRemote, destination, @@ -719,6 +704,7 @@ export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( changeSet, destination, rootDocument: request.source.loaded.document ?? {}, + sourceDocument: configPullSourceDocument(request.source), projectRef: ref, }); const planWithDefectCheck = yield* configPullDefectAndUnpushableCheck(plan, fixpoint.residual); @@ -761,6 +747,7 @@ export const applyConfigPullRun = Effect.fn("ConfigPull.apply")(function* (input readonly source: ConfigPullSource; }) { const fs = yield* FileSystem.FileSystem; + const configValues = yield* CliConfigValues; const { plan, context, configFilePath } = input.runPlan; yield* Effect.annotateCurrentSpan("config.write_count", plan.writes.length); @@ -798,12 +785,14 @@ export const applyConfigPullRun = Effect.fn("ConfigPull.apply")(function* (input message: `cannot write ${context.configPath}: ${configPullRefusalPhrase(reason)}${location} — ${detail}. ${configPullRefusalRemediation(reason)}`, }); } - yield* writeCliConfigDocumentText(configFilePath, editOutcome.text).pipe( - Effect.catchTag( - "CliConfigWriteError", - (cause) => new ConfigPullWriteError({ message: cause.message }), - ), - ); + yield* configValues + .writeThrough(writeCliConfigDocumentText(configFilePath, editOutcome.text)) + .pipe( + Effect.catchTag( + "CliConfigWriteError", + (cause) => new ConfigPullWriteError({ message: cause.message }), + ), + ); }); interface ChangeStatus { diff --git a/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts b/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts index d619b6b5e3..4c72b7b8d2 100644 --- a/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.deep-missing-path.unit.test.ts @@ -1,47 +1,54 @@ import * as BunPath from "@effect/platform-bun/BunPath"; -import { Effect, Path } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { describe, expect, it } from "vitest"; + +import { resolveEmailTemplateContentPath } from "./config-validate.ts"; /** * Exercises `canonicalPathForContainment`'s ancestor walk-up with a 5,000-component missing * path — deeper than any real filesystem allows (`PATH_MAX` caps a real OS at a few thousand - * components), so `node:fs` is mocked here to drive the loop's own iteration count directly. - * Isolated into its own file because `vi.mock` is file-scoped and would break - * `config-validate.unit.test.ts`'s reliance on a real filesystem. + * components), so `FileSystem` is faked here to drive the loop's own iteration count directly. */ -vi.mock("node:fs", () => ({ - realpathSync: vi.fn((path: string) => { - if (path === FAKE_EXISTING_BASE) return FAKE_EXISTING_BASE; - throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { +const missingEntry = (method: string, syscall: string, path: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method, + syscall, + pathOrDescriptor: path, + cause: Object.assign(new Error(`ENOENT: no such file or directory, ${syscall} '${path}'`), { code: "ENOENT", - }); - }), - lstatSync: vi.fn(() => undefined), - readlinkSync: vi.fn(() => { - throw new Error("readlinkSync should never be reached — no path in this fixture is a symlink"); - }), - statSync: vi.fn(() => { - throw new Error("statSync should never be reached by the template content_path branch"); - }), -})); + }), + }); const FAKE_EXISTING_BASE = "/fake/project-root"; const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); +const fakeFileSystem = FileSystem.layerNoop({ + realPath: (path) => + path === FAKE_EXISTING_BASE + ? Effect.succeed(FAKE_EXISTING_BASE) + : Effect.fail(missingEntry("realPath", "realpath", path)), + /* The `lstat` stand-in: no path in this fixture exists, so none is a symlink. */ + readLink: (path) => Effect.fail(missingEntry("readLink", "readlink", path)), + stat: () => + Effect.die(new Error("`stat` should never be reached by the template content_path branch")), +}); describe("canonicalPathForContainment (via resolveEmailTemplateContentPath)", () => { - it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () => - import("./config-validate.ts").then(({ resolveEmailTemplateContentPath }) => { - const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); - const contentPath = `${missingSegments.join("/")}/invite.html`; + it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () => { + const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); + const contentPath = `${missingSegments.join("/")}/invite.html`; - const resolved = resolveEmailTemplateContentPath({ + const resolved = Effect.runSync( + resolveEmailTemplateContentPath({ section: "template", name: "invite", contentPath, contentPresent: false, base: FAKE_EXISTING_BASE, - }); + }).pipe(Effect.provide(Layer.mergeAll(fakeFileSystem, BunPath.layer))), + ); - expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath)); - })); + expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath)); + }); }); diff --git a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts index 17b8f86b53..662b9e3d9b 100644 --- a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts @@ -1,10 +1,8 @@ import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; +import { stringify as stringifyToml } from "smol-toml"; import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; @@ -13,6 +11,9 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { resolveStorageCredentials } from "./storage-credentials.ts"; import { resolveLocalConfigValues } from "./local-config-values.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; + +const stringifyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); /** * Cross-caller parity coverage: for a table of shared misconfigurations, drives both real @@ -28,31 +29,33 @@ import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; * the `baseConfig`/`WORKDIR` pattern from `local-config-values.unit.test.ts`. */ -function withConfig(content: string) { - const dir = mkdtempSync(join(tmpdir(), "config-validate-parity-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), content); - return dir; -} +const withConfig = (content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "config-validate-parity-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", "config.toml"), content); + return dir; + }).pipe(Effect.provide(BunServices.layer)); const readD = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(Layer.merge(BunServices.layer, cliConfigValuesTestLayer))); /** Drives D's real pipeline and asserts the failure message contains `message`. */ function failsWithD(tomlLines: ReadonlyArray, message: string) { return Effect.gen(function* () { - const dir = withConfig(tomlLines.join("\n")); + const dir = yield* withConfig(tomlLines.join("\n")); const exit = yield* readD(dir).pipe(Effect.exit); expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(message); + expect(stringifyJson(exit.cause)).toContain(message); } - rmSync(dir, { recursive: true, force: true }); - }); + }).pipe(Effect.scoped); } const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -68,10 +71,13 @@ function failsWithL( message: string, document?: Readonly>, ) { - const config = baseConfig(overrides); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)).toThrow( - message, - ); + return Effect.gen(function* () { + const config = baseConfig(overrides); + const error = yield* Effect.flip( + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document), + ); + expect(error.message).toContain(message); + }).pipe(Effect.provide(BunServices.layer)); } interface ParityScenario { @@ -289,12 +295,26 @@ const scenarios: ReadonlyArray = [ message: "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", }, + { + name: "custom auth.external provider enabled without client_id", + toml: ["[auth.external.my_oidc]", "enabled = true"], + overrides: {}, + document: { auth: { external: { my_oidc: { enabled: true } } } }, + message: "Missing required field in config: auth.external.my_oidc.client_id", + }, + { + name: "custom auth.external provider enabled without secret", + toml: ["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"'], + overrides: {}, + document: { auth: { external: { my_oidc: { enabled: true, client_id: "cid" } } } }, + message: "Missing required field in config: auth.external.my_oidc.secret", + }, ]; // Explicitly skipped (only one caller runs the branch, or the branch isn't exercised the same // way by both — see the module header in `config-validate.ts` for the full out-of-scope list): -// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared -// validator (`ConfigValidationInput` has no fields for these at all). +// - `remotes[*].project_id` — D-only, never part of the shared validator +// (`ConfigValidationInput` has no field for it). // - `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. `api.tls` // presence is D-skipped for the same reason but is shared with S via // `validateApiTlsPresence` — see the S block below. @@ -303,7 +323,7 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => { it.effect(`${scenario.name}: D and L fail with the same message`, () => Effect.gen(function* () { yield* failsWithD(scenario.toml, scenario.message); - failsWithL(scenario.overrides, scenario.message, scenario.document); + yield* failsWithL(scenario.overrides, scenario.message, scenario.document); }), ); } @@ -315,13 +335,14 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => { // assert the identical message, so the shared branches cannot drift. describe("shared api + auth validation branches, cross-caller parity (S vs L)", () => { /** Drives S's real pipeline (`resolveStorageCredentials`, local branch) to failure. */ - const failsWithS = (config: CliConfig, message: string) => + const failsWithS = (overrides: Record, message: string) => Effect.gen(function* () { - const dir = withConfig(""); - const exit = yield* resolveStorageCredentials({ projectRef: "", config }).pipe( + const dir = yield* withConfig(stringifyToml({ project_id: "test", ...overrides })); + const exit = yield* resolveStorageCredentials({ projectRef: "" }).pipe( Effect.provide( Layer.mergeAll( unusedStackServices, + cliConfigValuesTestLayer, BunServices.layer, runtimeInfoLayer, mockCommandSettings({ workdir: dir }), @@ -336,10 +357,9 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", ); expect(Exit.isFailure(exit), `S: expected failure containing: ${message}`).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(message); + expect(stringifyJson(exit.cause)).toContain(message); } - rmSync(dir, { recursive: true, force: true }); - }); + }).pipe(Effect.scoped); // Ambient SUPABASE_API_* / SUPABASE_AUTH_* values would override the config // under test, so pin every participating key to unset for the duration of @@ -359,8 +379,8 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", isolated( Effect.gen(function* () { const message = "Missing required field in config: api.port"; - failsWithL({ api: { port: 0 } }, message); - yield* failsWithS(baseConfig({ api: { port: 0 } }), message); + yield* failsWithL({ api: { port: 0 } }, message); + yield* failsWithS({ api: { port: 0 } }, message); }), ), ); @@ -369,11 +389,8 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", isolated( Effect.gen(function* () { const message = "Missing required field in config: api.tls.key_path"; - failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); - yield* failsWithS( - baseConfig({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }), - message, - ); + yield* failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); + yield* failsWithS({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); }), ), ); @@ -382,22 +399,18 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", isolated( Effect.gen(function* () { const message = "Invalid config for auth.jwt_secret. Must be at least 16 characters"; - failsWithL({ auth: { jwt_secret: "short" } }, message); - yield* failsWithS(baseConfig({ auth: { jwt_secret: "short" } }), message); + yield* failsWithL({ auth: { jwt_secret: "short" } }, message); + yield* failsWithS({ auth: { jwt_secret: "short" } }, message); }), ), ); - it.effect( - "undecryptable encrypted: auth.service_role_key: S and L fail with the same message", - () => - isolated( - Effect.gen(function* () { - const message = "failed to parse config"; - const auth = { service_role_key: "encrypted:not-a-real-ciphertext" }; - failsWithL({ auth }, message); - yield* failsWithS(baseConfig({ auth }), message); - }), + it.effect("undecryptable encrypted: auth.service_role_key fails S at resolvedConfig load", () => + isolated( + failsWithS( + { auth: { service_role_key: "encrypted:not-a-real-ciphertext" } }, + "failed to parse config", ), + ), ); }); diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index 9549248426..dbec5fff8b 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -1,6 +1,6 @@ -import { statSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { Data, Effect, FileSystem, Path, type PlatformError } from "effect"; +import type { CliConfig } from "@supabase/config"; import { actionability, type CliErrorActionabilityDeclaration, @@ -9,21 +9,23 @@ import { } from "../shared/telemetry/error-actionability.ts"; import { canonicalPathForContainment, isPathContainedInRoot } from "./path-containment.ts"; import { BRANCH_PROJECT_REF_PATTERN } from "./ref-patterns.ts"; -import { goUrlParse } from "./storage-url.ts"; +import type { ResolvedAuthExternalProvider } from "./local-config-values.ts"; +import { parseUrl } from "./storage-url.ts"; /** * Single home for config validation, shared by the two config readers: * * - **D** = `db-config.toml-read.ts`: raw TOML document, Effect-based, fails with * `DbConfigLoadError`. - * - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, synchronous, - * throws `Error`. + * - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, Effect-based, + * fails with an `Error`. * * Per-command reimplementations of any branch {@link validateResolvedConfig} owns are forbidden; - * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` and - * `auth.sms`/`auth.external` need the raw pre-decode document, and `auth.jwt_secret` length lives - * in each key-generation flow. Each reader calls {@link validateResolvedConfig} once, after its - * own I/O reads, so an error from a branch here surfaces before a caller-only I/O error. + * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` needs the raw + * pre-decode document, and `auth.jwt_secret` length lives in each key-generation flow. Each reader + * calls {@link validateResolvedConfig} once, after its own I/O reads, so an error from a branch + * here surfaces before a caller-only I/O error. {@link validateAuthSmsProviders} and + * {@link validateAuthExternalProviders} are separate calls taking already-resolved inputs. */ // Re-exported under this module's established name; `ref-patterns.ts` is the canonical @@ -45,29 +47,17 @@ export const HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; export const CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; -// Accepted boolean string forms, matching Go's `strconv.ParseBool`; any other value is a -// parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parses a config bool value: accepts the same string forms as `strconv.ParseBool`, returns - * `undefined` for anything else (surfaced by callers as a `failed to parse config` error). - * Used by both D and L for `SUPABASE_*` bool-flavored env overrides and TOML bool decoding. - */ -export function parseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; - return undefined; -} - /** - * Thrown by {@link validateResolvedConfig}. Does not override `.name` — it stays the inherited + * Raised by {@link validateResolvedConfig} (thrown) and by the `content_path`/secret resolvers + * (failed). Does not override `.name` — it stays the inherited * `"Error"` — so `instanceof Error` and `.name` checks can't distinguish it from a plain * `Error`; only `.message` is meant to be observed. */ -export class ConfigValidateError extends Error { +export class ConfigValidateError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "ConfigValidateError"; + constructor(message: string) { + super({ message }); + } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } @@ -229,6 +219,10 @@ function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } +/** The `node:fs` error behind a `FileSystem` failure, whose message the config errors quote. */ +export const fsErrorCause = (error: PlatformError.PlatformError): unknown => + error.reason.cause ?? error.reason.description; + /** * The canonical `api.port` branch: an enabled API with a port of `0` is * invalid config. Exported so the storage-credentials resolver @@ -312,7 +306,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { throw new ConfigValidateError("Missing required field in config: studio.port"); } try { - goUrlParse(input.studio.apiUrl); + parseUrl(input.studio.apiUrl); } catch (cause) { throw new ConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); } @@ -340,7 +334,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { provider !== "turnstile" ) { throw new ConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", ); } if (auth.captcha.enabled) { @@ -377,12 +371,12 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { `Missing required field in config: auth.hook.${hook.type}.uri`, ); } - // Uses `goUrlParse`'s stricter semantics (the same port used for `studio.api_url` + // Uses `parseUrl`'s stricter semantics (the same port used for `studio.api_url` // above) so a malformed URI like an unterminated IPv6 host (`http://[::1`) fails the // whole load instead of passing a bare scheme-prefix regex. let scheme: string; try { - scheme = goUrlParse(hook.uri).scheme; + scheme = parseUrl(hook.uri).scheme; } catch (cause) { throw new ConfigValidateError(`failed to parse template url: ${messageOf(cause)}`); } @@ -537,7 +531,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { backend !== "bigquery" ) { throw new ConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + "failed to parse config:\nanalytics.backend: must be one of postgres, bigquery", ); } if (input.analytics.enabled && backend === "bigquery") { @@ -581,8 +575,14 @@ function isValidJson(value: string): boolean { } /** Absolute → verbatim; relative → join(workdir, "supabase", p). */ -export function resolveSigningKeysPath(workdir: string, signingKeysPath: string): string { - return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +export function resolveSigningKeysPath( + path: Path.Path, + workdir: string, + signingKeysPath: string, +): string { + return path.isAbsolute(signingKeysPath) + ? signingKeysPath + : path.join(workdir, "supabase", signingKeysPath); } /** `failed to read signing keys: ${msg(cause)}` */ @@ -598,7 +598,7 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string { // with the first key — that JWK-specific decode/signing logic stays in L. /** - * Pure exclusivity decision plus the path to read for one template/notification entry. Throws + * Exclusivity decision plus the path to read for one template/notification entry. Fails with * {@link ConfigValidateError} when `contentPath === ""` and `contentPresent` (`content_path` * is required instead of `content`). Returns the absolute, canonicalized path to read, or * `undefined` when there's nothing to read; a set `contentPath` always wins over `content` @@ -606,10 +606,10 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string { * * The resolved candidate and `base` are both canonicalized and the candidate must resolve * inside `base` — an absolute path, a `..` escape, or an in-root symlink pointing outside the - * project root all throw. This applies unconditionally to every caller (config validation, + * project root all fail. This applies unconditionally to every caller (config validation, * `config push` content loading, `start`'s pre-Docker containment pass) — there is no opt-out. */ -export function resolveEmailTemplateContentPath(args: { +export const resolveEmailTemplateContentPath = Effect.fnUntraced(function* (args: { readonly section: "template" | "notification"; readonly name: string; /** Post-env-expand; `""` = absent. */ @@ -617,35 +617,36 @@ export function resolveEmailTemplateContentPath(args: { /** Raw `content` key present in the TOML document. */ readonly contentPresent: boolean; readonly base: string; -}): string | undefined { +}) { if (args.contentPath.length === 0) { if (args.contentPresent) { - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, ); } return undefined; } + const path = yield* Path.Path; const candidate = args.section === "notification" - ? resolveNotificationContentPath(args.base, args.contentPath) - : isAbsolute(args.contentPath) + ? yield* resolveNotificationContentPath(args.base, args.contentPath) + : path.isAbsolute(args.contentPath) ? args.contentPath - : join(args.base, args.contentPath); - const resolvedCanonical = canonicalPathForContainment(candidate); - const canonicalBase = canonicalPathForContainment(args.base); - if (!isPathContainedInRoot(canonicalBase, resolvedCanonical)) { + : path.join(args.base, args.contentPath); + const resolvedCanonical = yield* canonicalPathForContainment(candidate); + const canonicalBase = yield* canonicalPathForContainment(args.base); + if (!isPathContainedInRoot(path, canonicalBase, resolvedCanonical)) { // Echoes the declared value, not `resolvedCanonical` — the declared value is already known // to the user (it's literally in config.toml or an env override they set), while echoing // the symlink-dereferenced target back would let a hostile config probe where it resolves. - throw new ConfigValidateError( + return yield* new ConfigValidateError( `Invalid config for auth.email.${args.section}.${args.name}.content_path: ` + `"${args.contentPath}" resolves outside the project root ${args.base} — ` + `move the file inside the project, or use a relative path that stays inside it.`, ); } return resolvedCanonical; -} +}); /** * Notification `content_path` resolution with a legacy fallback: older scaffolds documented @@ -656,15 +657,19 @@ export function resolveEmailTemplateContentPath(args: { * {@link probeFile}). Shared by config validation, `config push` content loading, and the Kong * template mount builder so every consumer sees the same file. */ -function resolveNotificationContentPath(base: string, contentPath: string): string { - if (isAbsolute(contentPath)) return contentPath; - const resolved = join(base, contentPath); - if (probeFile(resolved) === "missing") { - const fallbackResolved = join(base, "supabase", contentPath); - if (probeFile(fallbackResolved) === "exists") return fallbackResolved; +const resolveNotificationContentPath = Effect.fnUntraced(function* ( + base: string, + contentPath: string, +) { + const path = yield* Path.Path; + if (path.isAbsolute(contentPath)) return contentPath; + const resolved = path.join(base, contentPath); + if ((yield* probeFile(resolved)) === "missing") { + const fallbackResolved = path.join(base, "supabase", contentPath); + if ((yield* probeFile(fallbackResolved)) === "exists") return fallbackResolved; } return resolved; -} +}); /** * Tri-state existence probe for the notification legacy-fallback decision above: `"exists"` @@ -675,15 +680,20 @@ function resolveNotificationContentPath(base: string, contentPath: string): stri * must only be selected on a confirmed `"exists"`. A single boolean can't express both * defaults, so the tri-state lets each caller choose. */ -function probeFile(path: string): "exists" | "missing" | "unknown" { - try { - const stats = statSync(path, { throwIfNoEntry: false }); - if (stats === undefined) return "missing"; - return stats.isFile() ? "exists" : "missing"; - } catch { - return "unknown"; - } -} +const probeFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(path).pipe( + Effect.map((info): "exists" | "missing" | "unknown" => + info.type === "File" ? "exists" : "missing", + ), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.succeed("missing" as const), + () => Effect.succeed("unknown" as const), + ), + ); +}); /** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ export function emailContentPathReadErrorMessage( @@ -697,8 +707,8 @@ export function emailContentPathReadErrorMessage( // ── api.tls cert/key (path rule: NO isAbsolute guard) ── /** Unconditional join(workdir, "supabase", p) — `path.Join` absorbs a leading "/" too. */ -export function resolveApiTlsPath(workdir: string, p: string): string { - return join(workdir, "supabase", p); +export function resolveApiTlsPath(path: Path.Path, workdir: string, p: string): string { + return path.join(workdir, "supabase", p); } /** `failed to read TLS cert: ${msg(cause)}` */ @@ -710,3 +720,73 @@ export function apiTlsCertReadErrorMessage(cause: unknown): string { export function apiTlsKeyReadErrorMessage(cause: unknown): string { return `failed to read TLS key: ${messageOf(cause)}`; } + +/** + * Validates only the first enabled provider in the fixed priority order; a later + * enabled-but-incomplete provider is never checked. + */ +export function validateAuthSmsProviders(sms: CliConfig["auth"]["sms"]): void { + function requireField(provider: string, field: string, value: string | undefined): void { + if (value === undefined || value.length === 0) { + throw new ConfigValidateError( + `Missing required field in config: auth.sms.${provider}.${field}`, + ); + } + } + + if (sms.twilio.enabled) { + requireField("twilio", "account_sid", sms.twilio.account_sid); + requireField("twilio", "message_service_sid", sms.twilio.message_service_sid); + requireField("twilio", "auth_token", sms.twilio.auth_token); + return; + } + if (sms.twilio_verify.enabled) { + requireField("twilio_verify", "account_sid", sms.twilio_verify.account_sid); + requireField("twilio_verify", "message_service_sid", sms.twilio_verify.message_service_sid); + requireField("twilio_verify", "auth_token", sms.twilio_verify.auth_token); + return; + } + if (sms.messagebird.enabled) { + requireField("messagebird", "originator", sms.messagebird.originator); + requireField("messagebird", "access_key", sms.messagebird.access_key); + return; + } + if (sms.textlocal.enabled) { + requireField("textlocal", "sender", sms.textlocal.sender); + requireField("textlocal", "api_key", sms.textlocal.api_key); + return; + } + if (sms.vonage.enabled) { + requireField("vonage", "from", sms.vonage.from); + requireField("vonage", "api_key", sms.vonage.api_key); + requireField("vonage", "api_secret", sms.vonage.api_secret); + return; + } +} + +/** + * Validates required fields for every enabled `auth.external.` provider, including + * custom names `@supabase/config`'s schema drops at decode time, so `resolved` must come from + * `resolveAuthExternalProviders` over the effective document rather than the decoded config. + */ +export function validateAuthExternalProviders( + resolved: Readonly>, +): void { + for (const [name, provider] of Object.entries(resolved)) { + if (!provider.enabled) continue; + if (provider.clientId.length === 0) { + throw new ConfigValidateError( + `Missing required field in config: auth.external.${name}.client_id`, + ); + } + if ( + name !== "apple" && + name !== "google" && + (provider.secret === undefined || provider.secret.length === 0) + ) { + throw new ConfigValidateError( + `Missing required field in config: auth.external.${name}.secret`, + ); + } + } +} diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 6140bcd76f..daf5921a8f 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -1,16 +1,7 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readdirSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it, layer } from "@effect/vitest"; +import { Effect, FileSystem, Path, PlatformError, Predicate } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { BUCKET_NAME_PATTERN, @@ -21,27 +12,29 @@ import { ConfigValidateError, type AuthInput, type ConfigValidationInput, - parseGoBool, resolveEmailTemplateContentPath, validateResolvedConfig, } from "./config-validate.ts"; +import { parseBoolLiteral } from "../shared/config/config-bool.ts"; -describe("parseGoBool", () => { - it("accepts Go's strconv.ParseBool true forms", () => { +describe("parseBoolLiteral", () => { + it("accepts the true forms", () => { for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(parseGoBool(value)).toBe(true); + expect(parseBoolLiteral(value)).toBe(true); } }); - it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + it("accepts the false forms, including the empty string", () => { for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { - expect(parseGoBool(value)).toBe(false); + expect(parseBoolLiteral(value)).toBe(false); } }); - it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { - expect(parseGoBool("yes")).toBeUndefined(); - expect(parseGoBool("2")).toBeUndefined(); + it("returns undefined for a value outside the accepted set", () => { + expect(parseBoolLiteral("yes")).toBeUndefined(); + expect(parseBoolLiteral("2")).toBeUndefined(); + expect(parseBoolLiteral("tRuE")).toBeUndefined(); + expect(parseBoolLiteral("tRue")).toBeUndefined(); }); }); @@ -57,11 +50,11 @@ describe("PROJECT_REF_PATTERN", () => { }); describe("BUCKET_NAME_PATTERN", () => { - it("matches Go-legal bucket name characters", () => { + it("matches legal bucket name characters", () => { expect(BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); }); - it("rejects characters outside Go's bucketNamePattern", () => { + it("rejects characters outside the bucket name pattern", () => { expect(BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); expect(BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); }); @@ -84,7 +77,7 @@ describe("HOOK_SECRET_PATTERN", () => { expect(HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); }); - it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + it("rejects a secret that doesn't match the hook secret pattern", () => { expect(HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); }); }); @@ -98,7 +91,7 @@ describe("CLERK_DOMAIN_PATTERN", () => { expect(CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); }); - it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + it("rejects a domain that doesn't match the clerk domain pattern", () => { expect(CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); }); }); @@ -106,33 +99,31 @@ describe("CLERK_DOMAIN_PATTERN", () => { // Direct coverage for the containment check shared by every caller (config push's // loadAuthEmailContent, db-config.toml-read.ts, local-config-values.ts, start.handler.ts's // pre-Docker pass), cheaper to pin here than to re-derive through every caller's own fixtures. -describe("resolveEmailTemplateContentPath", () => { - let projectRoot = ""; - let outsideDir = ""; - - afterEach(() => { - if (projectRoot.length > 0) { - rmSync(projectRoot, { recursive: true, force: true }); - projectRoot = ""; - } - if (outsideDir.length > 0) { - rmSync(outsideDir, { recursive: true, force: true }); - outsideDir = ""; - } - }); +layer(BunServices.layer)("resolveEmailTemplateContentPath", (it) => { + const tempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); + }); - function setup(): string { - projectRoot = mkdtempSync(join(tmpdir(), "config-validate-email-content-")); - return projectRoot; - } + const setup = () => tempDir("config-validate-email-content-"); /** A real file outside `base`, so a containment test proves the escape check fires rather than a missing-file error. */ - function setupOutsideFile(): string { - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const outsideFile = join(outsideDir, "secret.html"); - writeFileSync(outsideFile, "

Outside

"); + const setupOutsideFile = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const outsideFile = path.join(outsideDir, "secret.html"); + yield* fs.writeFileString(outsideFile, "

Outside

"); return outsideFile; - } + }); + + /** Whether `chmod 000` actually blocks reading `dir` here (it doesn't when running as root). */ + const permissionEnforced = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return !(yield* fs.readDirectory(dir).pipe(Effect.isSuccess)); + }); function resolveContentPath( section: "template" | "notification", @@ -148,252 +139,361 @@ describe("resolveEmailTemplateContentPath", () => { }); } - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects an absolute %s content_path outside the project root", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); + (section) => + Effect.gen(function* () { + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; - expect(() => resolveContentPath(section, outsideFile, base)).toThrow(ConfigValidateError); - expect(() => resolveContentPath(section, outsideFile, base)).toThrow( - /resolves outside the project root/, - ); - }, + expect(yield* Effect.flip(resolveContentPath(section, outsideFile, base))).toBeInstanceOf( + ConfigValidateError, + ); + expect( + (yield* Effect.flip(resolveContentPath(section, outsideFile, base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a relative %s content_path that escapes the project root via ..", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); - const escapePath = relative(base, outsideFile); - - expect(() => resolveContentPath(section, escapePath, base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const path = yield* Path.Path; + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; + const escapePath = path.relative(base, outsideFile); + + expect((yield* Effect.flip(resolveContentPath(section, escapePath, base))).message).toMatch( + /resolves outside the project root/, + ); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root symlink pointing outside the project root", - (section) => { - const base = setup(); - const outsideFile = setupOutsideFile(); - const symlinkPath = join(base, "evil.html"); - symlinkSync(outsideFile, symlinkPath); - - expect(() => resolveContentPath(section, "./evil.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideFile = yield* setupOutsideFile; + const symlinkPath = path.join(base, "evil.html"); + yield* fs.symlink(outsideFile, symlinkPath); + + expect( + (yield* Effect.flip(resolveContentPath(section, "./evil.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it("accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", () => { - const base = setup(); - const dotDir = join(base, "..templates"); - mkdirSync(dotDir, { recursive: true }); - writeFileSync(join(dotDir, "invite.html"), "

Invite

"); - - const resolved = resolveContentPath("template", "..templates/invite.html", base); - - expect(resolved).toBe(join(realpathSync(base), "..templates", "invite.html")); - }); + it.effect( + "accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const dotDir = path.join(base, "..templates"); + yield* fs.makeDirectory(dotDir, { recursive: true }); + yield* fs.writeFileString(path.join(dotDir, "invite.html"), "

Invite

"); + + const resolved = yield* resolveContentPath("template", "..templates/invite.html", base); + + expect(resolved).toBe(path.join(yield* fs.realPath(base), "..templates", "invite.html")); + }), + ); - it("accepts a content_path that resolves to exactly the project root", () => { - const base = setup(); + it.effect("accepts a content_path that resolves to exactly the project root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const base = yield* setup(); - const resolved = resolveContentPath("template", ".", base); + const resolved = yield* resolveContentPath("template", ".", base); - expect(resolved).toBe(realpathSync(base)); - }); + expect(resolved).toBe(yield* fs.realPath(base)); + }), + ); - it("resolves a missing in-root file behind a symlinked project root instead of raising the containment error", () => { - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - - try { - const resolved = resolveContentPath("template", "missing-invite.html", symlinkedRoot); - expect(resolved).toBe(join(realpathSync(symlinkedRoot), "missing-invite.html")); - } finally { - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }); + it.effect( + "resolves a missing in-root file behind a symlinked project root instead of raising the containment error", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + + const resolved = yield* resolveContentPath( + "template", + "missing-invite.html", + symlinkedRoot, + ); + expect(resolved).toBe(path.join(yield* fs.realPath(symlinkedRoot), "missing-invite.html")); + }), + ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root dangling symlink pointing to a nonexistent target outside the project root", - (section) => { - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const neverCreatedOutsideTarget = join(outsideDir, "never-created.html"); - const danglingSymlinkPath = join(base, "dangling.html"); - symlinkSync(neverCreatedOutsideTarget, danglingSymlinkPath); - - expect(() => resolveContentPath(section, "./dangling.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./dangling.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const neverCreatedOutsideTarget = path.join(outsideDir, "never-created.html"); + const danglingSymlinkPath = path.join(base, "dangling.html"); + yield* fs.symlink(neverCreatedOutsideTarget, danglingSymlinkPath); + + expect( + yield* Effect.flip(resolveContentPath(section, "./dangling.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./dangling.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( - "rejects a %s content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", - (section) => { - // Skipped when this environment doesn't enforce chmod 000 (e.g. running as root); the - // dangling-symlink case above already covers the core regression without a permission trick. - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const unsearchableDir = join(outsideDir, "locked"); - mkdirSync(unsearchableDir); - const target = join(unsearchableDir, "secret.html"); - writeFileSync(target, "

Locked

"); - chmodSync(unsearchableDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(unsearchableDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { + it.effect.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root dangling symlink whose own mode denies reading it", + (section) => + Effect.gen(function* () { + /* Skipped where symlink modes can't be set or aren't enforced (Linux, root). */ + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const lockedSymlinkPath = path.join(base, "locked.html"); + yield* fs.symlink(path.join(outsideDir, "never-created.html"), lockedSymlinkPath); + const locked = yield* spawner + .exitCode(ChildProcess.make("chmod", ["-h", "000", lockedSymlinkPath])) + .pipe( + Effect.map((exitCode) => exitCode === 0), + Effect.orElseSucceed(() => false), + ); + if (!locked || (yield* fs.readLink(lockedSymlinkPath).pipe(Effect.isSuccess))) { return; } - const symlinkPath = join(base, "unsearchable.html"); - symlinkSync(target, symlinkPath); + const failure = yield* Effect.flip(resolveContentPath(section, "./locked.html", base)); + expect( + failure instanceof PlatformError.PlatformError && + Predicate.isTagged(failure.reason, "PermissionDenied"), + ).toBe(true); + }), + ); - expect(() => resolveContentPath(section, "./unsearchable.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./unsearchable.html", base)).toThrow( - /resolves outside the project root/, - ); - } finally { - chmodSync(unsearchableDir, 0o755); - } - }, + /* Linux ignores a symlink's own mode, so a `readLink` denied by the symlink itself is driven + through a `FileSystem` that denies it, with the parent `stat` passing or failing. */ + const denyingReadLink = (lockedPath: string, parentSearchable: boolean) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentProbe = `${path.dirname(lockedPath)}${path.sep}.`; + const parentInfo = yield* fs.stat(parentProbe); + const denied = (method: string, pathOrDescriptor: string) => + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor, + }); + return FileSystem.FileSystem.of({ + ...fs, + readLink: (p) => (p === lockedPath ? Effect.fail(denied("readLink", p)) : fs.readLink(p)), + stat: (p) => + p !== parentProbe + ? fs.stat(p) + : parentSearchable + ? Effect.succeed(parentInfo) + : Effect.fail(denied("stat", p)), + }); + }); + + it.effect("rejects a content_path whose own readLink is denied under a searchable parent", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const base = yield* setup(); + const lockedFs = yield* denyingReadLink(path.join(base, "locked.html"), true); + + const failure = yield* Effect.flip( + resolveContentPath("template", "./locked.html", base).pipe( + Effect.provideService(FileSystem.FileSystem, lockedFs), + ), + ); + expect( + failure instanceof PlatformError.PlatformError && + Predicate.isTagged(failure.reason, "PermissionDenied"), + ).toBe(true); + }), ); - it("accepts a genuinely in-root file behind an unsearchable (EACCES) directory, even when the project root itself is reached through a symlink", () => { - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - const lockedDir = join(realDir, "locked"); - mkdirSync(lockedDir); - const target = join(lockedDir, "invite.html"); - writeFileSync(target, "

Invite

"); - chmodSync(lockedDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(lockedDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { - return; - } - - const resolved = resolveContentPath("template", "./locked/invite.html", symlinkedRoot); - - expect(resolved).toBe(join(realpathSync(symlinkedRoot), "locked", "invite.html")); - } finally { - chmodSync(lockedDir, 0o755); - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }); + it.effect("accepts a content_path whose readLink is denied by an unsearchable parent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const lockedFs = yield* denyingReadLink(path.join(base, "locked.html"), false); + + const resolved = yield* resolveContentPath("template", "./locked.html", base).pipe( + Effect.provideService(FileSystem.FileSystem, lockedFs), + ); + expect(resolved).toBe(path.join(yield* fs.realPath(base), "locked.html")); + }), + ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", + (section) => + Effect.gen(function* () { + // Skipped when this environment doesn't enforce chmod 000 (e.g. running as root); the + // dangling-symlink case above already covers the core regression without a permission trick. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const unsearchableDir = path.join(outsideDir, "locked"); + yield* fs.makeDirectory(unsearchableDir); + const target = path.join(unsearchableDir, "secret.html"); + yield* fs.writeFileString(target, "

Locked

"); + yield* fs.chmod(unsearchableDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(unsearchableDir))) { + return; + } + + const symlinkPath = path.join(base, "unsearchable.html"); + yield* fs.symlink(target, symlinkPath); + + expect( + yield* Effect.flip(resolveContentPath(section, "./unsearchable.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./unsearchable.html", base))).message, + ).toMatch(/resolves outside the project root/); + }).pipe(Effect.ensuring(fs.chmod(unsearchableDir, 0o755).pipe(Effect.orDie))); + }), + ); + + it.effect( + "accepts a genuinely in-root file behind an unsearchable (EACCES) directory, even when the project root itself is reached through a symlink", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + const lockedDir = path.join(realDir, "locked"); + yield* fs.makeDirectory(lockedDir); + const target = path.join(lockedDir, "invite.html"); + yield* fs.writeFileString(target, "

Invite

"); + yield* fs.chmod(lockedDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(lockedDir))) { + return; + } + + const resolved = yield* resolveContentPath( + "template", + "./locked/invite.html", + symlinkedRoot, + ); + + expect(resolved).toBe( + path.join(yield* fs.realPath(symlinkedRoot), "locked", "invite.html"), + ); + }).pipe(Effect.ensuring(fs.chmod(lockedDir, 0o755).pipe(Effect.orDie))); + }), + ); + + it.effect.each(["template", "notification"] as const)( "rejects a %s content_path that is an in-root symlink pointing to an unstattable (ENAMETOOLONG) target name, without relying on directory permissions", - (section) => { - const base = setup(); - outsideDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-outside-")); - const tooLongName = `${"a".repeat(300)}.html`; - const symlinkPath = join(base, "toolong.html"); - try { - symlinkSync(join(outsideDir, tooLongName), symlinkPath); - } catch { + (section) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const outsideDir = yield* tempDir("config-validate-email-content-outside-"); + const tooLongName = `${"a".repeat(300)}.html`; + const symlinkPath = path.join(base, "toolong.html"); // Unprivileged symlink creation isn't unconditionally available (e.g. Windows without // Developer Mode/admin) — skip rather than fail an environment that can't set this up. - return; - } + if ( + !(yield* fs + .symlink(path.join(outsideDir, tooLongName), symlinkPath) + .pipe(Effect.isSuccess)) + ) { + return; + } - expect(() => resolveContentPath(section, "./toolong.html", base)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./toolong.html", base)).toThrow( - /resolves outside the project root/, - ); - }, + expect( + yield* Effect.flip(resolveContentPath(section, "./toolong.html", base)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./toolong.html", base))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it.each(["template", "notification"] as const)( + it.effect.each(["template", "notification"] as const)( "rejects an in-root %s symlink loop instead of hanging or crashing", - (section) => { - // Reuses the symlinked-root fixture (rather than relying on incidental ambient-tmpdir - // symlinks) so there's a guaranteed canonicalization gap between root and the loop path. - const realDir = mkdtempSync(join(tmpdir(), "config-validate-email-content-real-")); - const linkContainer = mkdtempSync(join(tmpdir(), "config-validate-email-content-link-")); - const symlinkedRoot = join(linkContainer, "project-root"); - symlinkSync(realDir, symlinkedRoot, "dir"); - - try { - const loopA = join(symlinkedRoot, "loop-a.html"); - const loopB = join(symlinkedRoot, "loop-b.html"); - symlinkSync(loopB, loopA); - symlinkSync(loopA, loopB); - - expect(() => resolveContentPath(section, "./loop-a.html", symlinkedRoot)).toThrow( - ConfigValidateError, - ); - expect(() => resolveContentPath(section, "./loop-a.html", symlinkedRoot)).toThrow( - /resolves outside the project root/, - ); - } finally { - rmSync(linkContainer, { recursive: true, force: true }); - rmSync(realDir, { recursive: true, force: true }); - } - }, + (section) => + Effect.gen(function* () { + // Reuses the symlinked-root fixture (rather than relying on incidental ambient-tmpdir + // symlinks) so there's a guaranteed canonicalization gap between root and the loop path. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* tempDir("config-validate-email-content-real-"); + const linkContainer = yield* tempDir("config-validate-email-content-link-"); + const symlinkedRoot = path.join(linkContainer, "project-root"); + yield* fs.symlink(realDir, symlinkedRoot); + + const loopA = path.join(symlinkedRoot, "loop-a.html"); + const loopB = path.join(symlinkedRoot, "loop-b.html"); + yield* fs.symlink(loopB, loopA); + yield* fs.symlink(loopA, loopB); + + expect( + yield* Effect.flip(resolveContentPath(section, "./loop-a.html", symlinkedRoot)), + ).toBeInstanceOf(ConfigValidateError); + expect( + (yield* Effect.flip(resolveContentPath(section, "./loop-a.html", symlinkedRoot))).message, + ).toMatch(/resolves outside the project root/); + }), ); - it("never selects an unstattable notification legacy-fallback twin behind an unsearchable (EACCES) supabase/ directory", () => { - const base = setup(); - const supabaseDir = join(base, "supabase"); - mkdirSync(supabaseDir); - chmodSync(supabaseDir, 0o000); - - try { - let permissionEnforced = true; - try { - readdirSync(supabaseDir); - permissionEnforced = false; - } catch { - // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. - } - if (!permissionEnforced) { - return; - } - - const resolved = resolveContentPath("notification", "notification.html", base); - - // The result must be the root-resolved path, not the unverified legacy twin; this - // function only resolves a path, it doesn't check that the file exists, so a non-throw - // here is expected. - expect(resolved).toBe(join(realpathSync(base), "notification.html")); - } finally { - chmodSync(supabaseDir, 0o755); - } - }); + it.effect( + "never selects an unstattable notification legacy-fallback twin behind an unsearchable (EACCES) supabase/ directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* setup(); + const supabaseDir = path.join(base, "supabase"); + yield* fs.makeDirectory(supabaseDir); + yield* fs.chmod(supabaseDir, 0o000); + + yield* Effect.gen(function* () { + if (!(yield* permissionEnforced(supabaseDir))) { + return; + } + + const resolved = yield* resolveContentPath("notification", "notification.html", base); + + // The result must be the root-resolved path, not the unverified legacy twin; this + // function only resolves a path, it doesn't check that the file exists, so a non-throw + // here is expected. + expect(resolved).toBe(path.join(yield* fs.realPath(base), "notification.html")); + }).pipe(Effect.ensuring(fs.chmod(supabaseDir, 0o755).pipe(Effect.orDie))); + }), + ); }); /** @@ -444,7 +544,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Missing required field in config: db.major_version"); }); - it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + it("rejects the unsupported Postgres 12.x major_version with its dedicated message", () => { expect(() => validateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), ).toThrow("Postgres version 12.x is unsupported."); @@ -464,7 +564,7 @@ describe("validateResolvedConfig", () => { }); describe("storage.buckets", () => { - it("rejects a bucket name Go's ValidateBucketName refuses", () => { + it("rejects an invalid bucket name", () => { expect(() => validateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), ).toThrow("Invalid Bucket name: bad/name."); @@ -948,7 +1048,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); }); - it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + it("rejects an http(s) hook secret that doesn't match the hook secret pattern", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1034,7 +1134,7 @@ describe("validateResolvedConfig", () => { ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); }); - it("rejects a hook uri that fails Go's url.Parse (malformed IPv6 host)", () => { + it("rejects a hook uri that fails URL parsing (malformed IPv6 host)", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1150,7 +1250,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); }); - it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + it("rejects clerk enabled with a domain that doesn't match the clerk domain pattern", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1211,7 +1311,7 @@ describe("validateResolvedConfig", () => { }); describe("functions.*", () => { - it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + it("rejects an invalid function slug", () => { expect(() => validateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( "Invalid Function name: 1bad.", ); @@ -1269,13 +1369,13 @@ describe("validateResolvedConfig", () => { // Coverage for behavior only meaningfully testable at this shared layer (e.g. the captcha // enum), not moved from either caller's own suite. describe("Config.Validate divergence regression coverage", () => { - it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + it("throws the missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { expect(() => validateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), ).toThrow("Missing required field in config: db.major_version"); }); - it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + it("throws the decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { // Unreachable through L's real flow: `@supabase/config`'s schema already narrows // `provider` to "hcaptcha" | "turnstile" | undefined, so an invalid value fails schema // decoding first. D's real TOML flow can reach this branch; its own suite covers that @@ -1289,7 +1389,7 @@ describe("validateResolvedConfig", () => { }), ), ).toThrow( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", ); }); }); diff --git a/apps/cli/src/command-internal/config-value-passthrough.ts b/apps/cli/src/command-internal/config-value-passthrough.ts new file mode 100644 index 0000000000..e8b68e4bbe --- /dev/null +++ b/apps/cli/src/command-internal/config-value-passthrough.ts @@ -0,0 +1,52 @@ +import { Effect, Option } from "effect"; + +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../config/cli-config.errors.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { describeConfigLoadFailure } from "./resolved-config-context.ts"; + +type ConfigValueFailure = CliConfigValueError | CliConfigFlagConflictError; + +/** A config failure that names the offending value or flag, and so reaches the user unwrapped. */ +export const isConfigValueFailure = (cause: unknown): cause is ConfigValueFailure => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause._tag === "CliConfigValueError" || cause._tag === "CliConfigFlagConflictError"); + +/** + * Loads the resolved config ahead of helpers that fold every load failure into one message, so an + * invalid value or flag conflict surfaces as-is and only other failures take `toError`. The load + * is memoised, so the helper that follows reuses it. + */ +export const loadResolvedConfigSurfacingValueErrors = ( + workdir: string, + toError: (message: string) => E, + projectRef: Option.Option = Option.none(), +) => + Effect.gen(function* () { + const values = yield* CliConfigValues; + return yield* values + .load({ workdir, projectRef }) + .pipe( + Effect.mapError((cause) => + isConfigValueFailure(cause) ? cause : toError(describeConfigLoadFailure(cause)), + ), + ); + }); + +/** Fails with an invalid value or flag conflict, and ignores every other load failure. */ +export const failOnInvalidConfigValue = ( + workdir: string, + projectRef: Option.Option = Option.none(), +) => + Effect.gen(function* () { + const values = yield* CliConfigValues; + yield* values + .load({ workdir, projectRef }) + .pipe( + Effect.catch((cause) => (isConfigValueFailure(cause) ? Effect.fail(cause) : Effect.void)), + ); + }); diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index d9eb65d081..7fd6054e1f 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -1,6 +1,5 @@ /** - * Connection-error classification and rendering ported from the established - * connect helpers and pgconn's `connectError`. + * Connection-error classification and rendering. * Used by the container-level pooler fallback (`db dump --linked`) to decide * whether a failed pg_dump/pg container was an IPv6 connectivity failure that * warrants retrying through the IPv4 transaction pooler, and by the connection @@ -13,13 +12,12 @@ import { aqua } from "./colors.ts"; /** * Command-agnostic hint shown when a direct connection fails because the host is IPv6-only, - * pointing users at the IPv4 transaction pooler via `--db-url`. Reproduces the established - * message text exactly, including the aqua-coloured `--db-url`. + * pointing users at the IPv4 transaction pooler via `--remote=`. */ export function ipv6Suggestion(): string { return ( "Your network does not support IPv6, which is required for direct connections to the database.\n" + - `Retry with your project's IPv4 transaction pooler connection string via ${aqua("--db-url")}.\n` + + `Retry with your project's IPv4 transaction pooler connection string via ${aqua("--remote=")}.\n` + "You can copy it from the dashboard under Connect > Transaction pooler." ); } @@ -29,7 +27,7 @@ export function ipv6Suggestion(): string { const IPV6_LITERAL_PATTERN = /(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))/; // Node's dial-failure shape (`connect ENETUNREACH 2600:…:5432`). The port may be // followed by whitespace, end-of-string, or a closing paren — the connect-failure -// message renders the driver cause parenthesized (pgconn `dial error (…)` form). +// message renders the driver cause parenthesized (`dial error (…)` form). const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:[\s)]|$)/i; /** @@ -64,6 +62,14 @@ export const SUGGEST_ENV_VAR = */ export const SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; +export const SUGGEST_LINKED_PROJECT = "To use the linked project instead, pass --remote."; + +export function localStackSuggestion(linkedCheckout: boolean | undefined): string { + return linkedCheckout === true + ? `${SUGGEST_LOCAL_STACK}\n${SUGGEST_LINKED_PROJECT}` + : SUGGEST_LOCAL_STACK; +} + /** * Remote-only "Network Restrictions" hint, shown for a connection refused or blocked by IP * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch @@ -79,6 +85,8 @@ export interface ConnectSuggestionContext { readonly dashboardUrl: string; /** Active profile name. */ readonly profileName: string; + /** Whether the checkout is linked, so a local failure can point at `--remote`. */ + readonly linkedCheckout?: boolean; } /** @@ -86,8 +94,8 @@ export interface ConnectSuggestionContext { * and `code`. The `@effect/sql` `SqlError` wraps the node-postgres/node `net` driver error on * its `cause`; a multi-address dial wraps an `AggregateError` whose `errors[]` carry the * per-IP `ECONNREFUSED`/`ENETUNREACH` system errors — an aggregate node contributes nothing - * itself and only its last child is visited, since pgconn's own fallback loop overwrites its - * error on every attempt so only the last one survives. The parent's own fields are skipped: + * itself and only its last child is visited, since each attempt overwrites the previous error + * so only the last one survives. The parent's own fields are skipped: * node's `aggregateErrors` copies `errors[0].code` onto the aggregate itself (`lib/internal/ * errors.js`, Bun matches), so reading them would blame an abandoned first attempt. */ @@ -126,7 +134,7 @@ export interface ConnectFailureTarget { * Walks to the deepest underlying driver error: unwraps `cause` chains (the `@effect/sql` * `SqlError` exposes its `ConnectionError` reason as `cause`, and the reason exposes the * node-postgres error the same way) and descends into the last entry of an `AggregateError`'s - * `errors[]` — pgconn's multi-address fallback loop likewise surfaces the last attempt's error. + * `errors[]` — the last attempt's error is the one surfaced. */ function deepestConnectCause(error: unknown): unknown { let current: unknown = error; @@ -146,8 +154,8 @@ function deepestConnectCause(error: unknown): unknown { return current; } -// Node/Bun errno codes raised while dialing the server — pgconn wraps the -// equivalent net.Dial failures as `dial error (…)`. +// Node/Bun errno codes raised while dialing the server, rendered as +// `dial error (…)`. const DIAL_ERROR_CODES = new Set([ "ECONNREFUSED", "ETIMEDOUT", @@ -179,8 +187,7 @@ export function isDialFailure(error: unknown): boolean { // The complete documented Node/OpenSSL X509 certificate-verification code // family (Node tls docs "X509 certificate error codes", OpenSSL's // `X509_verify_cert_error` set), complemented by node's ERR_TLS_*/ERR_SSL_* -// prefixes at the use site. pgconn stages by connection phase — any `startTLS` -// failure becomes `tls error (…)` — but node exposes no +// prefixes at the use site. Any TLS-phase failure renders as `tls error (…)`, but node exposes no // phase marker, so the full code family is the proxy. These strings are unique // to TLS-layer verification: server SQLSTATEs and dial/DNS `E…` errnos are // classified by earlier branches. @@ -215,13 +222,13 @@ const TLS_ERROR_CODES = new Set([ "HOSTNAME_MISMATCH", ]); // node-postgres' own message when the server answers `N` to SSLRequest -// (`pg/lib/connection.js`); pgconn: `tls error (server refused TLS connection)`. +// (`pg/lib/connection.js`); rendered as `tls error (server refused TLS connection)`. const SERVER_REFUSED_SSL = "The server does not support SSL connections"; // Node/Bun's TLS-socket message when the server accepts SSLRequest but closes // the socket before the handshake completes (node `lib/_tls_wrap.js` // `onConnectEnd`; Bun emits the same text for both FIN and RST). Phase-specific // by construction — only ever raised pre-secure-connection — so it maps to -// pgconn's startTLS stage (`tls error (…)`). Its code is +// the TLS stage (`tls error (…)`). Its code is // ECONNRESET, absent from DIAL_ERROR_CODES: a raw // post-handshake `read ECONNRESET` is not phase-specific and stays verbatim. const TLS_DISCONNECT_MESSAGE = @@ -240,24 +247,17 @@ const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/; export const isSqlState = (code: string): boolean => SQLSTATE_PATTERN.test(code); /** - * Render the underlying driver failure the way pgconn stages its - * `connectError.msg` (`server error` / `hostname resolving error` / + * Render the underlying driver failure by stage (`server error` / `hostname resolving error` / * `dial error` / `tls error`, each with the cause parenthesized). - * A server ErrorResponse reproduces pgconn's `PgError` - * rendering byte-for-byte (`Severity: Message (SQLSTATE Code)`); - * for the other stages the parenthesized text is the node driver's own message, - * which cannot byte-match libpq/pgconn wording (e.g. node's - * `connect ECONNREFUSED 1.2.3.4:5432` vs - * `dial tcp 1.2.3.4:5432: connect: connection refused`). An unrecognized cause - * (e.g. node-postgres' `Connection terminated unexpectedly`, where pgconn would - * say `failed to receive message (unexpected EOF)`) is rendered verbatim rather + * A server ErrorResponse renders as `Severity: Message (SQLSTATE Code)`; + * for the other stages the parenthesized text is the node driver's own message + * (e.g. `connect ECONNREFUSED 1.2.3.4:5432`). An unrecognized cause + * (e.g. node-postgres' `Connection terminated unexpectedly`) is rendered verbatim rather * than guessing a stage. * - * Known stage-label caveat: pgconn labels by auth phase, which node-postgres - * does not expose — a wrong password over SCRAM arrives mid-SASL, so pgconn renders - * `failed SASL auth (FATAL: password authentication failed … (SQLSTATE 28P01))` - * where this renders `server error (…)` with the identical - * inner `PgError` bytes. The suggestion classifier keys off the inner text, so + * Known stage-label caveat: node-postgres does not expose the auth phase, so a wrong password + * over SCRAM arrives mid-SASL and renders as `server error (…)` with the same inner server-error + * text a SASL-stage label would carry. The suggestion classifier keys off the inner text, so * the `SUPABASE_DB_PASSWORD` hint fires identically either way. */ function connectCauseDetail(cause: unknown): string { @@ -293,10 +293,9 @@ function connectCauseDetail(cause: unknown): string { } /** - * Port of pgconn's `connectError.Error()`, the inner text of - * `failed to connect to postgres: %w` wrap: + * The inner text of the `failed to connect to postgres: ` wrap: * `` failed to connect to `host=… user=… database=…`: ``. - * Callers pass the connection config (pgconn embeds the config-level identity) + * Callers pass the connection config (its identity is embedded in the message) * and the raw failure — either the `@effect/sql` `SqlError` * from the pooled connect probe or the bare node-postgres error from the raw * client, both unwrapped by {@link deepestConnectCause}. @@ -344,7 +343,7 @@ function hasIPv6DialCause(error: unknown, depth = 0): boolean { * `SqlError` cause/aggregate chain. Returns `undefined` when no specific suggestion applies. * * The rendered message ({@link connectFailureMessage}) and this classifier inspect the same - * surfaced attempt: pgconn's fallback loop keeps only the last error on every attempt, so the + * surfaced attempt: only the last attempt's error survives, so the * collectors above descend into only the last aggregate child too — the displayed cause and * the suggestion can never disagree. */ @@ -360,7 +359,9 @@ export function connectSuggestion( } // Don't send the user to the dashboard's Network Restrictions page for a --local connection. if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { - return ctx.isLocal ? SUGGEST_LOCAL_STACK : suggestNetworkRestrictions(ctx.dashboardUrl); + return ctx.isLocal + ? localStackSuggestion(ctx.linkedCheckout) + : suggestNetworkRestrictions(ctx.dashboardUrl); } if ( text.includes("SCRAM exchange: Wrong password") || diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 06ecd7f09e..3b31e9d043 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -101,7 +101,7 @@ describe("connectFailureMessage", () => { ); }); - it("surfaces the last dial attempt of a dual-stack AggregateError (pgconn last-fallback parity)", () => { + it("surfaces the last dial attempt of a dual-stack AggregateError (last attempt wins)", () => { const aggregate = Object.assign(new AggregateError([], ""), { code: "ECONNREFUSED", errors: [ @@ -114,7 +114,7 @@ describe("connectFailureMessage", () => { ); }); - it("reproduces pgconn's server-error rendering byte-for-byte for a server ErrorResponse", () => { + it("renders a server ErrorResponse byte-for-byte", () => { expect(connectFailureMessage(target, realSqlConnectError(authFailedError()))).toBe( `${prefix} server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))`, ); @@ -274,6 +274,13 @@ describe("connectSuggestion", () => { expect(connectSuggestion(err, { ...ctx, isLocal: true })).toBe(SUGGEST_LOCAL_STACK); }); + it("points a refused local connection at --remote when the checkout is linked", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(connectSuggestion(err, { ...ctx, isLocal: true, linkedCheckout: true })).toBe( + `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --remote.`, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { @@ -336,7 +343,7 @@ describe("connectSuggestion", () => { expect(connectSuggestion(realSqlConnectError(aggregate), ctx)).toBe(ipv6Suggestion()); }); - it("classifies only the LAST attempt of a mixed-family aggregate (pgconn last-fallback parity)", () => { + it("classifies only the LAST attempt of a mixed-family aggregate (last attempt wins)", () => { const aggregate = Object.assign(new AggregateError([], ""), { code: "EHOSTUNREACH", errors: [ @@ -382,7 +389,7 @@ describe("connectSuggestion", () => { ); }); - it("sets no suggestion for a mid-handshake TLS disconnect, like Go", () => { + it("sets no suggestion for a mid-handshake TLS disconnect", () => { const midHandshake = Object.assign( new Error("Client network socket disconnected before secure TLS connection was established"), { code: "ECONNRESET" }, @@ -390,7 +397,7 @@ describe("connectSuggestion", () => { expect(connectSuggestion(realSqlConnectError(midHandshake), ctx)).toBeUndefined(); }); - it("keeps an IPv4 EADDRNOTAVAIL unclassified, like Go without an IPv6 literal", () => { + it("keeps an IPv4 EADDRNOTAVAIL unclassified without an IPv6 literal", () => { const err = realSqlConnectError(dialError("EADDRNOTAVAIL", "10.1.2.3", 5432)); expect(connectSuggestion(err, ctx)).toBeUndefined(); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts index de3f84d05a..cb35de6488 100644 --- a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts @@ -149,7 +149,7 @@ describe("awaitStorageReady", () => { ); it.effect( - "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins Go's hardcoded 30s constant", + "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins the hardcoded 30s timeout", () => Effect.gen(function* () { const mock = mockSpawner((args) => { diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index 067ce31ae0..53952e7881 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -1,7 +1,6 @@ /** - * Resolves the config fields `startDatabase` needs from `config`/`projectEnvValues`, folding in - * any `SUPABASE_*` override. Shared by `db start` and `supabase start` so each field's derivation - * has one home. + * Resolves the config fields `startDatabase` needs from the effective `config`. Shared by + * `db start` and `supabase start` so each field's derivation has one home. * * Excludes `--exclude` gate evaluation, JWKS resolution, and the Postgres registry-image resolve: * their timing differs between the two callers, so `startDatabase` takes them as a @@ -12,36 +11,22 @@ import type { CliConfig } from "@supabase/config"; import { Effect, type FileSystem, type Path } from "effect"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; -import { makeRemoteWins } from "../db-config.toml-read.ts"; import { resolveDbImage } from "../db-image.ts"; -import { resolveHealthTimeoutSeconds } from "../go-duration.ts"; -import { - envOverride, - envOverrideBool, - envOverrideMajorVersion, - envOverrideOrioleDBVersion, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - InvalidRealtimeIpVersionEnvOverrideError, -} from "../local-config-values.ts"; +import { resolveHealthTimeoutSeconds } from "../duration.ts"; +import { narrowConfigEnum } from "../local-config-values.ts"; import { InvalidServiceVersionTagError, readServiceVersionOverrides, } from "../service-version-overrides.ts"; import { ramInBytes } from "../size-units.ts"; -import { legacyProjectStatePaths } from "../legacy-project-state.paths.ts"; +import { legacyProjectStatePaths } from "../../shared/config/legacy-project-state.paths.ts"; import { usesStackState } from "../stack-backend.ts"; export interface DbBootstrapConfigInput { + /** The effective config: every override is already applied. */ readonly config: CliConfig; - readonly projectEnvValues: Readonly> | undefined; + readonly projectEnvValues?: Readonly> | undefined; readonly workdir: string; - /** - * Config keys a matched `[remotes.]` block set at override tier; every `envOverride*` call - * below must not re-apply a `SUPABASE_*` value for a field this set already covers. Defaults to - * empty for `db start`/`db reset`, which never resolve a remote block here. - */ - readonly remoteOverrideKeys?: ReadonlySet; } export interface DbBootstrapConfig { @@ -67,33 +52,14 @@ export interface DbBootstrapConfig { readonly storageTargetMigration: string; } -/** - * Wraps a synchronous `envOverride*` read that throws on a malformed value into a typed failure - * instead of an untyped Effect defect. - * - * @param dottedFieldPath - Config path embedded in the error message (`invalid config for - * : `). - */ -function wrapConfigOverride( - dottedFieldPath: string, - thunk: () => T, - mapConfigError: (message: string) => E, -): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - mapConfigError( - `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); -} +const configOverrideMessage = (dottedFieldPath: string, cause: unknown) => + `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`; /** - * Resolves every field {@link startDatabase} needs from `config`/`projectEnvValues`: values - * already folded with any `SUPABASE_*` override, ready to feed the Postgres container spec and - * the fresh-volume setup pipeline. + * Resolves every field {@link startDatabase} needs from the effective `config`, ready to feed the + * Postgres container spec and the fresh-volume setup pipeline. * - * @param mapConfigError - Lets each caller tag a malformed-override failure with its own error + * @param mapConfigError - Lets each caller tag a malformed-value failure with its own error * type. */ export const resolveDbBootstrapConfig = ( @@ -103,132 +69,31 @@ export const resolveDbBootstrapConfig = ( mapConfigError: (message: string) => E, ): Effect.Effect => Effect.gen(function* () { - const { config, projectEnvValues, workdir } = input; - const remoteOverrideKeys = input.remoteOverrideKeys ?? new Set(); - const remoteWins = makeRemoteWins(remoteOverrideKeys); + const { config, workdir } = input; - // Not wrapped: checkDbToml already validates this override. A matched remote block's value - // wins over a conflicting SUPABASE_DB_MAJOR_VERSION. - const majorVersion = remoteWins("db.major_version") - ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. - // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env - // precedence as `majorVersion` applies to each. - const orioledbVersion = remoteWins("db.orioledb_version") - ? config.db.orioledb_version - : envOverrideOrioleDBVersion(config.db.orioledb_version, projectEnvValues); - const s3Host = remoteWins("experimental.s3_host") - ? config.experimental.s3_host - : envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", config.experimental.s3_host, projectEnvValues); - const s3Region = remoteWins("experimental.s3_region") - ? config.experimental.s3_region - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_REGION", - config.experimental.s3_region, - projectEnvValues, - ); - const s3AccessKey = remoteWins("experimental.s3_access_key") - ? config.experimental.s3_access_key - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - config.experimental.s3_access_key, - projectEnvValues, - ); - const s3SecretKey = remoteWins("experimental.s3_secret_key") - ? config.experimental.s3_secret_key - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - config.experimental.s3_secret_key, - projectEnvValues, - ); + const majorVersion = config.db.major_version; + const orioledbVersion = config.db.orioledb_version; + const { s3_host: s3Host, s3_region: s3Region } = config.experimental; + const { s3_access_key: s3AccessKey, s3_secret_key: s3SecretKey } = config.experimental; - // The one-shot fresh-DB setup jobs use the effective, overridden enabled value and run - // regardless of `--exclude`. Wrapping here is `db start`'s only protection against a - // malformed override; it has no `--exclude`/gates equivalent of its own. - const realtimeEnabledForSetup = yield* wrapConfigOverride( - "realtime.enabled", - () => - remoteWins("realtime.enabled") - ? config.realtime.enabled - : envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), - mapConfigError, - ); - const storageEnabledForSetup = yield* wrapConfigOverride( - "storage.enabled", - () => - remoteWins("storage.enabled") - ? config.storage.enabled - : envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), - mapConfigError, - ); - const authEnabledForSetup = yield* wrapConfigOverride( - "auth.enabled", - () => - remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ), - mapConfigError, - ); - - // The long-running Realtime container and the one-shot PG15+ setup job must see the same - // overridden value. - const realtimeIpVersion = yield* wrapConfigOverride( - "realtime.ip_version", - () => { - // `envOverrideRealtimeIpVersion` reads `process.env` unconditionally, so it can't be - // called at all when the remote block wins — a raw env var would still beat it. The - // throw below only narrows the type; the schema already guarantees one of these values. - if (remoteWins("realtime.ip_version")) { - const value = config.realtime.ip_version; - if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); - } - return value; - } - return envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); - }, - mapConfigError, - ); - const realtimeMaxHeaderLength = yield* wrapConfigOverride( - "realtime.max_header_length", - () => - remoteWins("realtime.max_header_length") - ? config.realtime.max_header_length - : envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), - mapConfigError, - ); + const realtimeEnabledForSetup = config.realtime.enabled; + const storageEnabledForSetup = config.storage.enabled; + const authEnabledForSetup = config.auth.enabled; + const realtimeIpVersion = yield* Effect.try({ + try: () => + narrowConfigEnum("realtime.ip_version", config.realtime.ip_version, ["IPv4", "IPv6"]), + catch: (cause) => mapConfigError(configOverrideMessage("realtime.ip_version", cause)), + }); + const realtimeMaxHeaderLength = config.realtime.max_header_length; - // Same reasoning as Realtime's IP version above. `@supabase/config`'s schema stores - // `file_size_limit` as a plain string without parsing it, so a malformed value must be - // validated eagerly here rather than surfacing later inside a container env builder. - const storageFileSizeLimit = remoteWins("storage.file_size_limit") - ? config.storage.file_size_limit - : (envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit); - yield* wrapConfigOverride( - "storage.file_size_limit", - () => ramInBytes(storageFileSizeLimit), - mapConfigError, - ); + // `@supabase/config`'s schema stores `file_size_limit` as a plain string without parsing it, + // so a malformed value must be validated eagerly here rather than surfacing later inside a + // container env builder. + const storageFileSizeLimit = config.storage.file_size_limit; + yield* Effect.try({ + try: () => ramInBytes(storageFileSizeLimit), + catch: (cause) => mapConfigError(configOverrideMessage("storage.file_size_limit", cause)), + }); // Reads a `supabase/.temp/postgres-version` pin written by `supabase link`; a missing or // unreadable pin falls back to the embedded default, so this never fails. @@ -248,11 +113,8 @@ export const resolveDbBootstrapConfig = ( majorVersion, ); - const dbHealthTimeout = remoteWins("db.health_timeout") - ? config.db.health_timeout - : envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); const dbHealthTimeoutSeconds = yield* Effect.try({ - try: () => resolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), + try: () => resolveHealthTimeoutSeconds(config.db.health_timeout), catch: (cause) => mapConfigError( `failed to parse config: ${cause instanceof Error ? cause.message : String(cause)}`, diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts index ee66e005ba..85457f9137 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -2,7 +2,8 @@ * Given a fully-resolved {@link StartContainerSpec}, sets the two project-identity labels, * provisions the container's own named volumes, builds the `docker create` argv, spawns `docker * create`, copies any `secretFiles` in via `docker cp` (see - * {@link copyStartSecretFilesIntoContainer}), then spawns `docker start`. + * {@link copyStartSecretFilesIntoContainer}), runs the caller's optional `beforeStart` hook, then + * spawns `docker start`. * * Network creation is not part of this per-container function; see {@link ensureNetwork} for why * it runs once instead of once per container. @@ -36,6 +37,7 @@ import { isDockerClientEnvKey, type StartContainerSpec, } from "./docker-create-args.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; type StartSecretFileSpec = NonNullable[number]; @@ -189,10 +191,10 @@ function portConflictSuggestion(hostPort: string, serviceLabel: string): string * Creates the shared docker network for this project via `docker network create --label ...`, * treating "already exists" as success. * - * Called once per `start` run rather than once per container: the network can't be deleted - * externally mid-run, so it's guaranteed to still exist for every later {@link createContainer} - * call. Returns immediately for a built-in network name (`default`, `bridge`, `host`, `none`), - * which already exists and cannot be created. + * Called once per `start` run rather than once per container: callers holding the project lifecycle + * lock keep other lifecycle commands from pruning it before every {@link createContainer} call has run. + * Returns immediately for a built-in network name (`default`, `bridge`, `host`, `none`), which + * already exists and cannot be created. */ export function ensureNetwork( spawner: Spawner, @@ -334,7 +336,7 @@ export class VolumeInspectError extends Data.TaggedError("VolumeInspectError")<{ } /** Docker's/Podman's "no such volume" stderr shape for `volume inspect`. */ -function isVolumeNotFoundMessage(message: string): boolean { +export function isVolumeNotFoundMessage(message: string): boolean { return /no such volume/iu.test(message); } @@ -645,10 +647,42 @@ function extractPreStartArchiveIntoContainer( ); } +/** Docker's, Podman's and older Docker clients' `cp` diagnostics for a missing `PG_VERSION`. */ +const PG_VERSION_MISSING_PATTERN = + /could not find the file \S*\/PG_VERSION in container|"\S*\/PG_VERSION" could not be found on container|no such container:path: \S+:\S*\/PG_VERSION/iu; + +/** + * Whether the Postgres data directory of a created, not-yet-started container already holds a + * cluster (`PG_VERSION`, written by `initdb`). Only a diagnostic reporting `PG_VERSION` itself + * as missing resolves `false`; every other outcome resolves `true`, so an ambiguous probe never + * runs setup over real data. + */ +export const postgresDataInitialized = Effect.fn("ContainerLifecycle.postgresDataInitialized")( + function* (spawner: Spawner, containerId: string) { + const initialized = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["cp", `${containerId}:${POSTGRES_DATA_DIR}/PG_VERSION`, "-"], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ); + return exitCode === 0 || !PG_VERSION_MISSING_PATTERN.test(stderr); + }), + ).pipe(Effect.orElseSucceed(() => true)); + yield* Effect.annotateCurrentSpan({ "db.data_initialized": initialized }); + return initialized; + }, +); + /** * Provisions this container's named volumes (skipped under Bitbucket Pipelines), creates it, * copies any `secretFiles` and `preStartArchives` in via `docker cp` while it's created but not - * yet running (so its entrypoint never races the copy), then starts it. + * yet running (so its entrypoint never races the copy), runs `beforeStart` with the container id + * while it is still stopped, then starts it. * * Resolves to the created container's id/name on success. */ @@ -656,6 +690,7 @@ export function createContainer( spawner: Spawner, spec: StartContainerSpec, opts: ContainerOpts, + beforeStart?: (containerId: string) => Effect.Effect, ): Effect.Effect { return Effect.gen(function* () { const labels: Record = { @@ -711,6 +746,9 @@ export function createContainer( }).pipe(Effect.orElseSucceed(() => 0)), ), ); + if (beforeStart !== undefined) { + yield* beforeStart(containerId); + } yield* dockerStartContainer(spawner, containerId, finalSpec); return containerId; }).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts index 255ae0d3b9..648474c306 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.unit.test.ts @@ -15,6 +15,7 @@ import { removeContainer, removeVolume, createContainer, + postgresDataInitialized, volumeExists, } from "./container-lifecycle.ts"; import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; @@ -524,6 +525,40 @@ describe("createContainer secretFiles", () => { }); }); +describe("createContainer beforeStart", () => { + it.live( + "runs the hook with the created container id after the secret copy and before start", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "container-id-hook\n" }; + return { exitCode: 0 }; + }); + const spec: StartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return createContainer( + mock.spawner, + spec, + { projectId: "proj", isBitbucketPipeline: false, workdir: workdir.current, extraHosts: [] }, + (containerId) => postgresDataInitialized(mock.spawner, containerId).pipe(Effect.asVoid), + ).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-hook"); + expect(mock.spawned).toEqual([ + expect.arrayContaining(["create"]), + ["cp", "-", "container-id-hook:/"], + ["cp", "container-id-hook:/var/lib/postgresql/data/PG_VERSION", "-"], + ["start", "container-id-hook"], + ]); + }), + ); + }, + ); +}); + describe("ensureNetwork", () => { it.live("creates the network with labels when it does not exist yet", () => { const mock = mockSpawner((args) => @@ -696,17 +731,14 @@ describe("volumeExists", () => { ); }); - it.live( - "resolves true (protected, not fresh) on an ambiguous inspect failure, matching Go's IsNotFound gate", - () => { - const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return volumeExists(mock.spawner, "supabase_db_proj").pipe( - Effect.map((exists) => { - expect(exists).toBe(true); - }), - ); - }, - ); + it.live("resolves true (protected, not fresh) on an ambiguous inspect failure", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return volumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }); it.live("fails with VolumeInspectError when no runtime can be spawned", () => { const spawner = ChildProcessSpawner.make(() => @@ -728,6 +760,88 @@ describe("volumeExists", () => { }); }); +describe("postgresDataInitialized", () => { + const pgVersionProbe = ["cp", "container-id-pg:/var/lib/postgresql/data/PG_VERSION", "-"]; + + it.live("resolves true when PG_VERSION can be copied out (cp exits 0)", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + expect(mock.spawned).toEqual([pgVersionProbe]); + }), + ); + }); + + const missingFileStderr = { + Docker: + "Error response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container container-id-pg\n", + Podman: + 'Error: "/var/lib/postgresql/data/PG_VERSION" could not be found on container container-id-pg: no such file or directory\n', + "an older Docker client": + "Error: No such container:path: container-id-pg:/var/lib/postgresql/data/PG_VERSION\n", + "Docker after a config warning": + "WARNING: Error loading config file: open /home/me/.docker/config.json: no such file or directory\nError response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container container-id-pg\n", + }; + for (const [runtime, stderr] of Object.entries(missingFileStderr)) { + it.live(`resolves false when ${runtime} reports the PG_VERSION file missing`, () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(false); + }), + ); + }); + } + + const otherFailureStderr = { + "a missing container": "Error response from daemon: No such container: container-id-pg\n", + "a permission error": "Error response from daemon: permission denied\n", + "an unreachable daemon": "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + "an unreadable Docker CLI config": + "open /home/me/.docker/config.json: no such file or directory\n", + "a Docker socket that does not exist": + 'error during connect: Head "http://%2Fvar%2Frun%2Fdocker.sock/_ping": dial unix /var/run/docker.sock: connect: no such file or directory\n', + "a Docker socket that does not exist when requesting the PG_VERSION archive": + 'error during connect: Head "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/container-id-pg/archive?path=%2Fvar%2Flib%2Fpostgresql%2Fdata%2FPG_VERSION": dial unix /var/run/docker.sock: connect: no such file or directory\n', + "a Podman socket that does not exist": + 'Error: unable to connect to Podman socket: Get "http://d/v5.2.0/libpod/_ping": dial unix /run/user/1000/podman/podman.sock: connect: no such file or directory\n', + "a Podman socket that does not exist when requesting the PG_VERSION archive": + 'Error: unable to connect to Podman socket: Get "http://d/v5.2.0/libpod/containers/container-id-pg/archive?path=%2Fvar%2Flib%2Fpostgresql%2Fdata%2FPG_VERSION": dial unix /run/user/1000/podman/podman.sock: connect: no such file or directory\n', + "a Docker config warning beside an unrelated PG_VERSION error": + "WARNING: Error loading config file: open /home/me/.docker/config.json: no such file or directory\nError response from daemon: permission denied reading /var/lib/postgresql/data/PG_VERSION\n", + "empty stderr": "", + }; + for (const [cause, stderr] of Object.entries(otherFailureStderr)) { + it.live(`resolves true (never re-seeds) on a non-zero exit with ${cause}`, () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + }), + ); + }); + } + + it.live("resolves true when no container runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return postgresDataInitialized(spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + }), + ); + }); +}); + describe("removeContainer", () => { it.live("spawns `docker container rm -f ` and succeeds on exit 0", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index b4f1d11471..d823034fb0 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -24,15 +24,20 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; import type { DbConnectError, DbExecError } from "../db-connection.errors.ts"; +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../../config/cli-config.errors.ts"; +import type { ProjectRefReadError } from "../../shared/config/legacy-project-state.service.ts"; import { DbConfigLoadError } from "../db-config.errors.ts"; -import { checkDbToml, resolveSeedSqlPath } from "../db-config.toml-read.ts"; +import { checkDbToml } from "../db-config.toml-read.ts"; import { CLI_PROJECT_LABEL, localDbContainerId } from "../docker-ids.ts"; import { DockerRun, type DockerRunOpts } from "../docker-run.service.ts"; import { migrateAndSeed } from "../migrate-and-seed.ts"; import { MigrationApplyError, execSqlFile } from "../migration-apply.ts"; import { readMigrationTable } from "../migration-history.ts"; import { statementInstallsPgNet } from "../pg-net-guidance.ts"; -import type { MigrationSeedError, SeedConfig } from "../seed.ts"; +import type { MigrationSeedError } from "../seed.ts"; import { ramInBytes } from "../size-units.ts"; import { MigrationVaultError, type VaultSecret, upsertVaultSecrets } from "../vault.ts"; import { ensureImagesCached, type ImagePrepullError } from "./image-prepull.ts"; @@ -44,6 +49,7 @@ import { START_DB_INITIAL_SCHEMA_13_SQL } from "./templates/db-initial-schema-13 import { START_DB_INITIAL_SCHEMA_14_SQL } from "./templates/db-initial-schema-14.sql.ts"; import { STACK_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; import { startInternalDbPassword, startInternalDbUrl } from "./internal-db-connection.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -117,6 +123,9 @@ function dbSetupDockerReason( /** Every failure {@link startSetupLocalDatabase} can produce. */ export type StartSetupLocalDatabaseError = | DbConfigLoadError + | ProjectRefReadError + | CliConfigValueError + | CliConfigFlagConflictError | DbSetupError | MigrationVaultError | MigrationApplyError @@ -280,32 +289,6 @@ export interface StartSetupLocalDatabaseInput extends Omit< * `db reset`'s PG15 recreate passes its own resolved reset version instead. */ readonly version: string; - /** - * `db reset`'s `--no-seed`/`--sql-paths` overrides. `db start` has neither flag, so its caller - * passes `{ noSeed: false, sqlPaths: [] }`, which {@link resolveResetSeedConfig} reduces to the - * loaded `[db.seed]` config unchanged. - */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; -} - -/** - * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved `[db.seed]` - * config: `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` force-enables - * seeding and overrides `sqlPaths` (each pattern resolved against `supabase/`); an empty - * `--sql-paths` is a no-op. The two flags are mutually exclusive, validated by the caller before - * this runs. - */ -export function resolveResetSeedConfig( - seed: SeedConfig, - override: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }, - path: Path.Path, -): SeedConfig { - if (override.noSeed) return { ...seed, enabled: false }; - if (override.sqlPaths.length === 0) return seed; - return { - enabled: true, - sqlPaths: override.sqlPaths.map((pattern) => resolveSeedSqlPath(path, pattern)), - }; } const errMessage = (e: unknown): string => @@ -863,7 +846,7 @@ export const startSetupLocalDatabase = ( // Rides alongside the alias because a pooled-connection acquire failure surfaces the driver's // connect error verbatim, suggestion included. StartSetupLocalDatabaseError | DbConnectError, - Output | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path + Output | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path | CliConfigValues > => Effect.gen(function* () { const { session, fs, path, workdir } = input; @@ -886,7 +869,7 @@ export const startSetupLocalDatabase = ( // env-overridden config, not re-read from the caller's raw `CliConfig`. yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + seed: toml.seed, experimental: input.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, @@ -1011,8 +994,8 @@ export const runDatabaseWebhooksSetup = (input: { /** * Runs {@link startSetupLocalDatabase} against a freshly-provisioned local Postgres: dials the * host-facing session, resolves the {@link resolveDbSetupPrelude} prelude, then runs the setup - * pipeline. `version`/`seedFlags` are the one difference between callers — `db start` always - * passes `""`/a no-op override, `db reset` passes its own resolved reset version and flags. + * pipeline. `version` is the one difference between callers — `db start` always passes `""`, + * `db reset` passes its own resolved reset version. */ export const runFreshDbSetup = ( spawner: Spawner, @@ -1025,13 +1008,18 @@ export const runFreshDbSetup = ( readonly hostname: string; readonly dbPort: number; readonly version: string; - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; readonly setup: FreshDbSetupInput; }, ): Effect.Effect< void, StartSetupLocalDatabaseError | DbConnectError | ImagePrepullError | E, - Output | DbConnection | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path + | Output + | DbConnection + | DockerRun + | RuntimeInfo + | FileSystem.FileSystem + | Path.Path + | CliConfigValues > => Effect.scoped( Effect.gen(function* () { @@ -1072,7 +1060,6 @@ export const runFreshDbSetup = ( projectEnvValues: setup.projectEnvValues, debug: setup.debug, version: input.version, - seedFlags: input.seedFlags, }); }), ).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts index 3644219133..f785d197b3 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts @@ -17,6 +17,7 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/process"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { DbExecError } from "../db-connection.errors.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; @@ -202,7 +203,6 @@ function baseInput( projectEnvValues: undefined, debug: false, version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, ...overrides, }; } @@ -227,6 +227,7 @@ const run = ( out.layer, docker.layer, mockRuntimeInfo({ platform: "darwin" }), + cliConfigValuesTestLayer, ), ), ); @@ -366,32 +367,29 @@ describe("startSetupLocalDatabase", () => { }, ); - it.effect( - "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", - () => { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workdir = yield* makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - // Default config: realtime, storage, and auth are all enabled — 3 jobs. - yield* run( - baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), - out, - docker, - ); - expect(docker.runs.length).toBe(3); - for (const job of docker.runs) { - expect(job.labels).toEqual({ - "com.supabase.cli.project": "labeled-proj", - "com.docker.compose.project": "labeled-proj", - }); - } - yield* fs.remove(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, - ); + it.effect("labels every one-shot job with the project's Docker labels", () => { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + // Default config: realtime, storage, and auth are all enabled — 3 jobs. + yield* run( + baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), + out, + docker, + ); + expect(docker.runs.length).toBe(3); + for (const job of docker.runs) { + expect(job.labels).toEqual({ + "com.supabase.cli.project": "labeled-proj", + "com.docker.compose.project": "labeled-proj", + }); + } + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); it.effect( "the realtime job's env matches `buildRealtimeEnv` on the internal db address + jwks", @@ -492,31 +490,28 @@ describe("startSetupLocalDatabase", () => { }, ); - it.effect( - "--debug tees every one-shot job's stderr, matching Go's utils.GetDebugLogger()", - () => { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workdir = yield* makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: true }, - storage: { enabled: false }, - auth: { enabled: true }, - }); - yield* run( - baseInput(workdir, session, { majorVersion: 15, config, debug: true }), - out, - docker, - ); - expect(docker.runs.length).toBe(2); - expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); - yield* fs.remove(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, - ); + it.effect("--debug tees every one-shot job's stderr", () => { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + yield* run( + baseInput(workdir, session, { majorVersion: 15, config, debug: true }), + out, + docker, + ); + expect(docker.runs.length).toBe(2); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); + yield* fs.remove(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); it.effect("without --debug, one-shot jobs run with teeStderr off", () => { return Effect.gen(function* () { @@ -684,47 +679,41 @@ describe("resolveDbSetupPrelude", () => { ); }); - it.effect( - 'prints the banner BEFORE a JWKS resolution failure — matching Go\'s "initSchema" printing the banner before ever calling "initSchema15" -> "ResolveJWKS" (review: PRRT_kwDOErm0O86W6R-O)', - () => { - const out = mockOutput(); - const jwksFailure = new Error("jwks discovery failed"); - return run( - { - majorVersion: 15, - realtimeEnabledForSetup: true, - jwks: Effect.fail(jwksFailure), - }, - out, - ).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toBe("jwks discovery failed"); - const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); - expect(banner.length).toBe(1); - expect(banner[0]?.stream).toBe("stderr"); - }), - ); - }, - ); + it.effect("prints the banner BEFORE a JWKS resolution failure", () => { + const out = mockOutput(); + const jwksFailure = new Error("jwks discovery failed"); + return run( + { + majorVersion: 15, + realtimeEnabledForSetup: true, + jwks: Effect.fail(jwksFailure), + }, + out, + ).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toBe("jwks discovery failed"); + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }); - it.effect( - "does not resolve JWKS on PG <= 14 even with realtime enabled — Go's initSchema never reaches initSchema15 there", - () => { - const out = mockOutput(); - let jwksCalled = false; - const jwks = Effect.sync(() => { - jwksCalled = true; - return "unused"; - }); - return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( - Effect.map((resolved) => { - expect(jwksCalled).toBe(false); - expect(resolved.jwks).toBe(""); - }), - ); - }, - ); + it.effect("does not resolve JWKS on PG <= 14 even with realtime enabled", () => { + const out = mockOutput(); + let jwksCalled = false; + const jwks = Effect.sync(() => { + jwksCalled = true; + return "unused"; + }); + return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( + Effect.map((resolved) => { + expect(jwksCalled).toBe(false); + expect(resolved.jwks).toBe(""); + }), + ); + }); }); /** diff --git a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts index 21f248b19a..39aa659629 100644 --- a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts @@ -226,7 +226,7 @@ describe("buildStartContainerCreateArgs", () => { ); }); - test("emits cmd tokens after the image even when entrypoint is absent (Pooler: start.go:1234-1237)", () => { + test("emits cmd tokens after the image even when entrypoint is absent", () => { const spec: StartContainerSpec = { image: "supabase/supavisor:2.0.0", containerName: "supabase_pooler_proj", @@ -247,7 +247,7 @@ describe("buildStartContainerCreateArgs", () => { ]); }); - test("omits health flags entirely when healthcheck is absent (Kong/PostgREST: start.go:975)", () => { + test("omits health flags entirely when healthcheck is absent", () => { const spec: StartContainerSpec = { image: "kong:3", containerName: "supabase_kong_proj", diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index fdd1e16c51..eb39284721 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -17,8 +17,13 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { ChildTracePropagation } from "../../shared/telemetry/spans.ts"; import { spawnContainerCliWithRuntime, type ContainerRuntime } from "../container-cli.ts"; +import { yellow } from "../colors.ts"; import { DbConnection, type PgConnInput } from "../db-connection.service.ts"; -import { inspectContainerState } from "../docker-lifecycle.ts"; +import { + formatNotReadyReason, + healthCheckCannotExec, + inspectContainerState, +} from "../docker-lifecycle.ts"; import { kongAuthHeaders } from "../kong-auth.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -72,6 +77,8 @@ export class HealthCheckTimeoutError extends Data.TaggedError("HealthCheckTimeou * logger is a non-sequitur. */ readonly suggestion?: string; + /** Containers accepted as ready on `running` alone because Docker could not exec their health check. */ + readonly readinessAssumed?: ReadonlyArray; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.startStack; @@ -79,18 +86,31 @@ export class HealthCheckTimeoutError extends Data.TaggedError("HealthCheckTimeou } /** - * PostgREST's local Kong gateway coordinates. TLS/CA trust for a local https gateway is the - * caller's responsibility when composing the `HttpClient.HttpClient` layer this module requires - * — when `api.tls.enabled`, the caller resolves the local Kong CA and overrides the HTTP client - * around the health-check call, so `checkHttpReady` trusts it instead of exhausting the full - * retry budget on a TLS verification failure. + * The local Kong gateway coordinates. TLS/CA trust for a local https gateway is the caller's + * responsibility when composing the `HttpClient.HttpClient` layer this module requires — when + * `api.tls.enabled`, the caller resolves the local Kong CA and overrides the HTTP client around + * the health-check call, so `checkHttpReady` trusts it instead of exhausting the full retry + * budget on a TLS verification failure. */ -export interface HealthCheckPostgrestGateway { - readonly containerId: string; +interface HealthCheckGateway { readonly apiExternalUrl: string; readonly secretKey: string; } +/** A {@link HealthCheckGateway} that probes one specific container instead of its Docker health. */ +export interface HealthCheckPostgrestGateway extends HealthCheckGateway { + readonly containerId: string; +} + +/** + * How to judge a container whose Docker health check cannot run: accept it as `running`, probe + * its route through the gateway, or (for the gateway itself) accept any HTTP response from it. + */ +export type ExecFallbackProbe = + | { readonly kind: "running" } + | { readonly kind: "gateway"; readonly gateway: HealthCheckGateway; readonly path: string } + | { readonly kind: "gateway-reachable"; readonly gateway: HealthCheckGateway }; + export interface WaitForHealthyServicesOptions { readonly timeoutSeconds?: number; readonly postgrest?: HealthCheckPostgrestGateway; @@ -98,6 +118,15 @@ export interface WaitForHealthyServicesOptions { readonly edgeRuntime?: HealthCheckPostgrestGateway; /** Each watched container's already-resolved image, keyed by container name. */ readonly images?: ReadonlyMap; + /** Per-container fallback used only when Docker cannot run that container's health check. */ + readonly execFallbacks?: ReadonlyMap; +} + +export interface WaitForHealthyServicesResult { + /** Containers whose Docker health check could not run, at any point during the wait. */ + readonly execUnavailable: ReadonlyArray; + /** The subset accepted on `running` alone, with no readiness verification. */ + readonly readinessAssumed: ReadonlyArray; } /** The container runtime's message when an image cannot be executed by the host kernel. */ @@ -156,42 +185,99 @@ function execFormatRecoveryHint( ].join("\n"); } +interface ExecFallbackSeen { + readonly execUnavailable: Set; + readonly assumed: Set; +} + /** Not-running/not-ready gating, via the same {@link inspectContainerState} primitive `status.handler.ts` uses. */ -function checkContainerReady(spawner: Spawner, containerId: string): Effect.Effect { +function checkContainerReady( + spawner: Spawner, + containerId: string, + fallback: ExecFallbackProbe | undefined, + seen: ExecFallbackSeen, +): Effect.Effect { return inspectContainerState(spawner, containerId).pipe( Effect.mapError((cause) => cause.message), - Effect.flatMap((state) => { + Effect.flatMap((state): Effect.Effect => { if (!state.running) { return Effect.fail(`container is not running: ${state.status}`); } - if (state.health !== undefined && state.health !== "healthy") { - return Effect.fail(`container is not ready: ${state.health}`); + if (state.health === undefined || state.health === "healthy") { + return Effect.void; } - return Effect.void; + if (healthCheckCannotExec(state)) { + seen.execUnavailable.add(containerId); + if (fallback?.kind === "running") { + seen.assumed.add(containerId); + return Effect.void; + } + if (fallback?.kind === "gateway") { + return checkHttpReady(fallback.gateway, fallback.path, "GET").pipe( + Effect.mapError((reason) => `container is not ready: ${fallback.path}: ${reason}`), + ); + } + if (fallback?.kind === "gateway-reachable") { + return checkHttpReady(fallback.gateway, "/", "GET", true).pipe( + Effect.mapError((reason) => `container is not ready: ${reason}`), + ); + } + } + return Effect.fail(formatNotReadyReason(state)); }), ); } -/** An HTTP HEAD through the local Kong gateway, expecting exactly 200. */ +/** An HTTP request through the local Kong gateway, expecting exactly 200 unless `anyStatus`. */ function checkHttpReady( - gateway: HealthCheckPostgrestGateway, + gateway: HealthCheckGateway, path: string, + method: "HEAD" | "GET" = "HEAD", + anyStatus = false, ): Effect.Effect { return Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; - const request = HttpClientRequest.head(`${gateway.apiExternalUrl}${path}`).pipe( - HttpClientRequest.setHeaders(kongAuthHeaders(gateway.secretKey)), - ); - const response = yield* httpClient.execute(request).pipe( + const url = `${gateway.apiExternalUrl}${path}`; + const request = ( + method === "GET" ? HttpClientRequest.get(url) : HttpClientRequest.head(url) + ).pipe(HttpClientRequest.setHeaders(kongAuthHeaders(gateway.secretKey))); + const status = yield* Effect.gen(function* () { + const response = yield* httpClient.execute(request); + if (method === "GET") { + yield* response.text.pipe(Effect.ignore); + } + return response.status; + }).pipe( Effect.timeout(`${HTTP_PROBE_TIMEOUT_SECONDS} seconds`), Effect.mapError((cause) => String(cause)), ); - if (response.status !== 200) { - return yield* Effect.fail(`unexpected status ${response.status}`); + if (!anyStatus && status !== 200) { + return yield* Effect.fail(`unexpected status ${status}`); } }); } +function execUnavailableHint(containerIds: ReadonlyArray): string | undefined { + const [first] = containerIds; + if (first === undefined) return undefined; + return [ + `Docker could not run the health check inside ${containerIds.join(", ")} ("OCI runtime exec failed"), so it cannot report them healthy.`, + "This happens when Docker runs nested or sandboxed.", + `Check with: docker exec ${first} true`, + ].join("\n"); +} + +/** The stderr warning for a start that proceeded although Docker cannot run health checks here. */ +export function healthCheckExecUnavailableWarning(unverified: ReadonlyArray): string { + return ( + `${yellow("WARNING:")} Docker cannot run health checks inside containers on this host (OCI runtime exec failed). ` + + (unverified.length > 0 + ? `Readiness of ${unverified.join(", ")} was not verified.` + : "Readiness was checked from the host instead.") + + "\n" + ); +} + /** * `docker logs `, teed to this process's stderr. Best-effort: a failure to stream logs must * never mask the timeout error it was printed alongside, so every failure here is swallowed. @@ -221,13 +307,13 @@ function streamContainerLogsOnce( [ Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); stdoutScanner.scan(chunk); }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); stderrScanner.scan(chunk); }), ), @@ -256,40 +342,46 @@ function dumpContainerLogs( ): Effect.Effect { return Effect.gen(function* () { yield* Effect.sync(() => { - globalThis.process.stderr.write(`${containerId} container logs:\n`); + process.stderr.write(`${containerId} container logs:\n`); }); return yield* streamContainerLogsOnce(spawner, containerId); }); } /** - * Waits for every container in `containerIds` to become ready. Resolves once all are ready; - * fails with {@link HealthCheckTimeoutError} (carrying every still-unhealthy container's - * last-seen reason) once the retry budget is exhausted. The caller decides whether - * `--ignore-health-check` turns that failure into a warning instead of a hard exit. + * Waits for every container in `containerIds` to become ready. Resolves once all are ready, + * reporting which containers Docker could not health-check; fails with + * {@link HealthCheckTimeoutError} (carrying every still-unhealthy container's last-seen reason) + * once the retry budget is exhausted. The caller decides whether `--ignore-health-check` turns + * that failure into a warning instead of a hard exit. */ export function waitForHealthyServices( spawner: Spawner, containerIds: ReadonlyArray, opts: WaitForHealthyServicesOptions = {}, -): Effect.Effect { +): Effect.Effect { const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; - const checkOne = (containerId: string): Effect.Effect => { + const checkOne = ( + containerId: string, + seen: ExecFallbackSeen, + ): Effect.Effect => { if (postgrest !== undefined && containerId === postgrest.containerId) { return checkHttpReady(postgrest, POSTGREST_READY_PATH); } if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { return checkHttpReady(edgeRuntime, EDGE_RUNTIME_READY_PATH); } - return checkContainerReady(spawner, containerId); + return checkContainerReady(spawner, containerId, opts.execFallbacks?.get(containerId), seen); }; return Effect.gen(function* () { const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); let stillWatching = containerIds; + let lastFailures: ReadonlyArray | undefined; let attempts = 0; + const seen: ExecFallbackSeen = { execUnavailable: new Set(), assumed: new Set() }; // Each round narrows `stillWatching` to just the containers that failed, so a container // that becomes healthy mid-run stops being probed on later rounds. @@ -299,7 +391,7 @@ export function waitForHealthyServices( const outcomes = yield* Effect.forEach( stillWatching, (containerId) => - checkOne(containerId).pipe( + checkOne(containerId, seen).pipe( Effect.match({ onFailure: (reason): HealthCheckFailure | undefined => ({ containerId, @@ -315,6 +407,7 @@ export function waitForHealthyServices( ); stillWatching = failures.map((failure) => failure.containerId); if (failures.length > 0) { + lastFailures = failures; return yield* new HealthCheckProbeError({ failures }); } }, @@ -323,8 +416,28 @@ export function waitForHealthyServices( // A 1-second constant delay, capped at `timeoutSeconds` retries after the initial attempt. const schedule = Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(timeoutSeconds)]); + const boundSeconds = (timeoutSeconds + 1) * (1 + HTTP_PROBE_TIMEOUT_SECONDS); + yield* probe.pipe( Effect.retry(schedule), + Effect.timeoutOrElse({ + duration: Duration.seconds(boundSeconds), + orElse: () => + Effect.annotateCurrentSpan("health.probe_bound_reached", true).pipe( + Effect.andThen( + Effect.fail( + new HealthCheckProbeError({ + failures: + lastFailures ?? + stillWatching.map((containerId) => ({ + containerId, + reason: `container is not ready: readiness probe hung after ${boundSeconds}s`, + })), + }), + ), + ), + ), + }), Effect.catch((probeError) => Effect.gen(function* () { // An interrupted fiber never reaches this `Effect.catch` handler, so no separate @@ -337,17 +450,25 @@ export function waitForHealthyServices( const execFormatErrors = scans .filter((scan) => scan.found) .map((scan) => scan.containerId); - const suggestion = execFormatRecoveryHint( - execFormatErrors, - opts.images, - scans.find((scan) => scan.runtime !== undefined)?.runtime ?? "docker", - ); + const hints = [ + execFormatRecoveryHint( + execFormatErrors, + opts.images, + scans.find((scan) => scan.runtime !== undefined)?.runtime ?? "docker", + ), + execUnavailableHint( + probeError.failures + .map((failure) => failure.containerId) + .filter((containerId) => seen.execUnavailable.has(containerId)), + ), + ].filter((hint) => hint !== undefined); return yield* new HealthCheckTimeoutError({ message: probeError.failures .map((failure) => `${failure.containerId} ${failure.reason}`) .join("\n"), unhealthy: probeError.failures, - ...(suggestion === undefined ? {} : { suggestion }), + ...(hints.length === 0 ? {} : { suggestion: hints.join("\n\n") }), + ...(seen.assumed.size === 0 ? {} : { readinessAssumed: [...seen.assumed] }), }); }), ), @@ -356,10 +477,16 @@ export function waitForHealthyServices( Effect.annotateCurrentSpan({ "retry.attempt_count": attempts, "health.unhealthy_count": stillWatching.length, + "health.exec_unavailable_count": seen.execUnavailable.size, + "health.readiness_assumed_count": seen.assumed.size, }), ), ), ); + return { + execUnavailable: [...seen.execUnavailable], + readinessAssumed: [...seen.assumed], + }; }).pipe( Effect.withSpan("HealthCheck.waitHealthyServices", { attributes: { "health.container_count": containerIds.length }, @@ -368,18 +495,18 @@ export function waitForHealthyServices( } /** Caps a hung dial so one probe cannot swallow the whole poll budget. */ -const SHADOW_READY_CONNECT_TIMEOUT_SECONDS = 2; +const POSTGRES_READY_CONNECT_TIMEOUT_SECONDS = 2; /** * One round's verdict. `fatal` makes an exited container fail fast instead of burning the * remaining budget: nothing about a dead container can change on a later round. */ -interface ShadowReadyFailure { +interface PostgresReadyFailure { readonly reason: string; readonly fatal: boolean; } -const shadowNotReady = (reason: string): ShadowReadyFailure => ({ +const postgresNotReady = (reason: string): PostgresReadyFailure => ({ reason, fatal: false, }); @@ -390,36 +517,36 @@ const shadowNotReady = (reason: string): ShadowReadyFailure => ({ * the real connection too. `Effect.scoped` closes the session the moment the probe resolves; the * caller opens its own connection afterwards. */ -const probeShadowConnect = ( +const probePostgresConnect = ( connConfig: PgConnInput, -): Effect.Effect => +): Effect.Effect => Effect.scoped( Effect.gen(function* () { const dbConnection = yield* DbConnection; yield* dbConnection.connect( - { ...connConfig, connectTimeoutSeconds: SHADOW_READY_CONNECT_TIMEOUT_SECONDS }, + { ...connConfig, connectTimeoutSeconds: POSTGRES_READY_CONNECT_TIMEOUT_SECONDS }, { isLocal: true, dnsResolver: "native" }, ); }), - ).pipe(Effect.mapError((cause) => shadowNotReady(cause.message))); + ).pipe(Effect.mapError((cause) => postgresNotReady(`container is not ready: ${cause.message}`))); -export interface WaitForShadowReadyOptions { +export interface WaitForPostgresReadyOptions { readonly timeoutSeconds?: number; - /** The shadow container's already-resolved postgres image, named in the exec-format recovery hint. */ + /** The container's already-resolved postgres image, named in the exec-format recovery hint. */ readonly image?: string; } /** - * Shadow readiness: inspect + connect probe, polled every 500ms. Docker's - * healthcheck is `interval=10s` with no start period, so Postgres is connectable - * ~6.5s before `Health.Status` flips. Same error shape as the health gate, - * plus a wall-clock cap so a hung 2s dial cannot overrun the retry budget. + * Postgres readiness: inspect + connect probe, polled every 500ms. Docker's health status needs + * `docker exec`, which some hosts cannot do, and it lags connectability by up to one 10s + * interval. Same error shape as the health gate, plus a wall-clock cap so a hung 2s dial cannot + * overrun the retry budget. */ -export function waitForShadowReady( +export function waitForPostgresReady( spawner: Spawner, containerId: string, connConfig: PgConnInput, - opts: WaitForShadowReadyOptions = {}, + opts: WaitForPostgresReadyOptions = {}, ): Effect.Effect { return Effect.gen(function* () { const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); @@ -430,17 +557,17 @@ export function waitForShadowReady( Schedule.spaced("500 millis"), Schedule.recurs(timeoutSeconds * 2), ]); - const boundSeconds = timeoutSeconds + SHADOW_READY_CONNECT_TIMEOUT_SECONDS; + const boundSeconds = timeoutSeconds + POSTGRES_READY_CONNECT_TIMEOUT_SECONDS; // Per-evaluation state: the retry rounds within one evaluation share the latest failure for // the timeout diagnostic, while re-evaluating the returned Effect starts from a fresh slot. - let lastFailure: ShadowReadyFailure | undefined; + let lastFailure: PostgresReadyFailure | undefined; let attempts = 0; - const probe: Effect.Effect = Effect.gen(function* () { + const probe: Effect.Effect = Effect.gen(function* () { attempts += 1; const state = yield* inspectContainerState(spawner, containerId).pipe( - Effect.mapError((cause) => shadowNotReady(cause.message)), + Effect.mapError((cause) => postgresNotReady(cause.message)), ); if (!state.running) { return yield* Effect.fail({ @@ -448,7 +575,7 @@ export function waitForShadowReady( fatal: true, }); } - yield* probeShadowConnect(connConfig); + yield* probePostgresConnect(connConfig); }).pipe( Effect.withTracerEnabled(false), Effect.provideService(ChildTracePropagation, false), @@ -466,7 +593,9 @@ export function waitForShadowReady( orElse: () => Effect.fail( lastFailure ?? - shadowNotReady(`not ready after ${boundSeconds}s: connection attempt hung`), + postgresNotReady( + `container is not ready: connection attempt hung after ${boundSeconds}s`, + ), ), }), Effect.catch((failure) => @@ -491,5 +620,31 @@ export function waitForShadowReady( Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), ), ); - }).pipe(Effect.withSpan("HealthCheck.waitShadowReady")); + }).pipe(Effect.withSpan("HealthCheck.waitPostgresReady")); +} + +/** + * Waits up to `maxSeconds` for Docker's first health verdict on a container, so a caller that + * reads `Health.Status` right after a host-side readiness probe does not see `starting`. + * Best-effort: a timeout or an inspect failure ends the wait without failing. + */ +export function awaitDockerHealthSettled( + spawner: Spawner, + containerId: string, + maxSeconds = 15, +): Effect.Effect { + const stillStarting = inspectContainerState(spawner, containerId).pipe( + Effect.map( + (state) => state.running && state.health === "starting" && !healthCheckCannotExec(state), + ), + Effect.orElseSucceed(() => false), + Effect.withTracerEnabled(false), + Effect.provideService(ChildTracePropagation, false), + ); + return stillStarting.pipe( + Effect.repeat({ schedule: Schedule.spaced("1 seconds"), while: (starting) => starting }), + Effect.timeout(Duration.seconds(maxSeconds)), + Effect.ignore, + Effect.withSpan("HealthCheck.awaitDockerHealthSettled"), + ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts index 7a8a369dde..81aba24804 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts @@ -3,16 +3,27 @@ import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream, Tracer } from "effe import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/process"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as TestClock from "effect/testing/TestClock"; +import { + EXEC_FAILED_STATE, + EXEC_FAILURE_OUTPUT, + PROBE_TIMEOUT_OUTPUT, + inspectedState, +} from "../../../tests/helpers/docker-health.ts"; import { DbConnectError } from "../db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "../db-connection.service.ts"; import { HealthCheckTimeoutError, + awaitDockerHealthSettled, + healthCheckExecUnavailableWarning, waitForHealthyServices, - waitForShadowReady, + waitForPostgresReady, + type ExecFallbackProbe, type HealthCheckPostgrestGateway, + type WaitForHealthyServicesOptions, } from "./health-check.ts"; /** Ends a scripted `logs` script, failing the stream after already-emitted chunks — the real "daemon dropped the pipe mid-dump" case. */ @@ -37,6 +48,8 @@ function mockHealthSpawner( opts: { readonly runtime?: "docker" | "podman"; readonly beforeInspect?: (containerId: string) => Effect.Effect; + /** Makes every `docker container inspect` exit non-zero, as when the daemon is unreachable. */ + readonly inspectFails?: boolean; } = {}, ) { const counts = new Map(); @@ -82,6 +95,7 @@ function mockHealthSpawner( let stdoutChunks: LogChunks = []; let stderrChunks: LogChunks = []; + let exitCode = 0; if (args[0] === "container" && args[1] === "inspect") { const containerId = args[2] ?? ""; if (opts.beforeInspect !== undefined) { @@ -89,7 +103,12 @@ function mockHealthSpawner( } const callIndex = counts.get(containerId) ?? 0; counts.set(containerId, callIndex + 1); - stdoutChunks = [inspectResponse(containerId, callIndex)]; + if (opts.inspectFails === true) { + exitCode = 1; + stderrChunks = ["Cannot connect to the Docker daemon\n"]; + } else { + stdoutChunks = [inspectResponse(containerId, callIndex)]; + } } else if (args[0] === "logs") { const script = logs[args[1] ?? ""]; if (script !== undefined) { @@ -103,7 +122,7 @@ function mockHealthSpawner( } const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(exitCode)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: toStream(stdoutChunks), @@ -332,6 +351,73 @@ describe("waitForHealthyServices", () => { }), ); + it.effect( + "fails with a hung-probe reason once the wall-clock bound passes when the first round never completes", + () => + withSilencedStderr( + Effect.gen(function* () { + const mock = mockHealthSpawner( + () => runningHealthy, + {}, + { beforeInspect: () => Effect.never }, + ); + + const fiber = yield* waitForHealthyServices(mock.spawner, ["supabase_kong_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + for (let second = 0; second < 21; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAt21Seconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + const reason = "container is not ready: readiness probe hung after 22s"; + expect(pendingAt21Seconds).toBeUndefined(); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(error.unhealthy).toEqual([{ containerId: "supabase_kong_proj", reason }]); + expect(error.message).toBe(`supabase_kong_proj ${reason}`); + }), + ), + ); + + it.effect( + "reports the last completed round's reason when a later round hangs past the wall-clock bound", + () => + withSilencedStderr( + Effect.gen(function* () { + let inspects = 0; + const mock = mockHealthSpawner( + () => runningStarting, + {}, + { beforeInspect: () => (++inspects > 1 ? Effect.never : Effect.void) }, + ); + + const fiber = yield* waitForHealthyServices(mock.spawner, ["supabase_kong_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + for (let second = 0; second < 21; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAt21Seconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(pendingAt21Seconds).toBeUndefined(); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_kong_proj", reason: "container is not ready: starting" }, + ]); + }), + ), + ); + describe("exec format error recovery advice", () => { /** * The dumped logs are teed to the real `process.stderr`; swallow them so @@ -880,7 +966,7 @@ function withSilencedStderr(effect: Effect.Effect): Effect.Eff const inspectCalls = (mock: ReturnType) => mock.spawned.filter((args) => args[0] === "container" && args[1] === "inspect"); -describe("waitForShadowReady", () => { +describe("waitForPostgresReady", () => { it.effect( "resolves on the first successful connect, while Docker still reports the healthcheck as starting", () => @@ -891,7 +977,7 @@ describe("waitForShadowReady", () => { const mock = mockHealthSpawner(() => runningStarting); const db = mockShadowDbConnection(); - const exit = yield* waitForShadowReady( + const exit = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -913,9 +999,14 @@ describe("waitForShadowReady", () => { const mock = mockHealthSpawner(() => runningStarting); const db = mockShadowDbConnection({ failTimes: 2 }); - const fiber = yield* waitForShadowReady(mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, { - timeoutSeconds: 30, - }).pipe(Effect.provide(db.layer), Effect.forkChild({ startImmediately: true })); + const fiber = yield* waitForPostgresReady( + mock.spawner, + SHADOW_CONTAINER_ID, + shadowConnConfig, + { + timeoutSeconds: 30, + }, + ).pipe(Effect.provide(db.layer), Effect.forkChild({ startImmediately: true })); yield* TestClock.adjust("1 seconds"); yield* TestClock.adjust("1 seconds"); @@ -938,7 +1029,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -953,9 +1044,14 @@ describe("waitForShadowReady", () => { expect(error).toBeInstanceOf(HealthCheckTimeoutError); expect(error.unhealthy).toEqual([ - { containerId: SHADOW_CONTAINER_ID, reason: "connection refused" }, + { + containerId: SHADOW_CONTAINER_ID, + reason: "container is not ready: connection refused", + }, ]); - expect(error.message).toBe(`${SHADOW_CONTAINER_ID} connection refused`); + expect(error.message).toBe( + `${SHADOW_CONTAINER_ID} container is not ready: connection refused`, + ); // 1 initial attempt plus `timeoutSeconds * 2` retries at 500ms intervals. expect(db.attempts).toHaveLength(5); expect( @@ -975,7 +1071,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -995,9 +1091,11 @@ describe("waitForShadowReady", () => { // Same failure the exhausted path produces: the last completed attempt's own reason, // the same ` ` message, and the same log dump. expect(error).toBeInstanceOf(HealthCheckTimeoutError); - expect(error.message).toBe(`${SHADOW_CONTAINER_ID} connection refused`); + expect(error.message).toBe( + `${SHADOW_CONTAINER_ID} container is not ready: connection refused`, + ); expect(error.unhealthy).toEqual([ - { containerId: SHADOW_CONTAINER_ID, reason: "connection refused" }, + { containerId: SHADOW_CONTAINER_ID, reason: "container is not ready: connection refused" }, ]); expect( mock.spawned.some((args) => args[0] === "logs" && args[1] === SHADOW_CONTAINER_ID), @@ -1015,7 +1113,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -1040,6 +1138,36 @@ describe("waitForShadowReady", () => { }), ); + it.effect("reports a first dial that never returns as not ready, naming the wall-clock cap", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningStarting); + const db = mockShadowDbConnection({ + failTimes: Number.POSITIVE_INFINITY, + connectMillis: 60_000, + }); + + const error = yield* withSilencedStderr( + Effect.gen(function* () { + const fiber = yield* waitForPostgresReady( + mock.spawner, + SHADOW_CONTAINER_ID, + shadowConnConfig, + { timeoutSeconds: 1 }, + ).pipe(Effect.provide(db.layer), Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("3 seconds"); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }), + ); + + const reason = "container is not ready: connection attempt hung after 3s"; + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(error.message).toBe(`${SHADOW_CONTAINER_ID} ${reason}`); + expect(error.unhealthy).toEqual([{ containerId: SHADOW_CONTAINER_ID, reason }]); + expect(db.attempts).toHaveLength(1); + }), + ); + describe("exec format error recovery advice", () => { it.effect("names the shadow's image when its dumped logs show exec format error", () => Effect.gen(function* () { @@ -1050,7 +1178,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -1081,7 +1209,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -1105,7 +1233,7 @@ describe("waitForShadowReady", () => { const error = yield* withSilencedStderr( Effect.gen(function* () { - const fiber = yield* waitForShadowReady( + const fiber = yield* waitForPostgresReady( mock.spawner, SHADOW_CONTAINER_ID, shadowConnConfig, @@ -1123,3 +1251,496 @@ describe("waitForShadowReady", () => { ); }); }); + +describe("waitForHealthyServices where Docker cannot exec health checks", () => { + const STUDIO = "supabase_studio_proj"; + const STORAGE = "supabase_storage_proj"; + const GATEWAY_URL = "http://127.0.0.1:54321"; + const STORAGE_STATUS_PATH = "/storage/v1/status"; + + const orderlyUnhealthy = inspectedState({ + health: "unhealthy", + probes: [{ exitCode: 1, output: "pg_isready: no response" }], + }); + const probeTimedOut = inspectedState({ + health: "unhealthy", + probes: [{ exitCode: -1, output: PROBE_TIMEOUT_OUTPUT }], + }); + + const runningFallback = (...containerIds: ReadonlyArray) => + new Map(containerIds.map((id) => [id, { kind: "running" }])); + const gatewayFallback = (containerId: string, path: string) => + new Map([ + [ + containerId, + { + kind: "gateway", + gateway: { apiExternalUrl: GATEWAY_URL, secretKey: "sb_secret_local" }, + path, + }, + ], + ]); + + const KONG = "supabase_kong_proj"; + const reachableFallback = (containerId: string) => + new Map([ + [ + containerId, + { + kind: "gateway-reachable", + gateway: { apiExternalUrl: GATEWAY_URL, secretKey: "sb_secret_local" }, + }, + ], + ]); + + function recordingGateway(statusByPath: Readonly> = {}) { + const requests: Array<{ + readonly method: string; + readonly url: string; + readonly headers: Readonly>; + }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push({ + method: request.method, + url: request.url, + headers: { ...request.headers }, + }); + const status = statusByPath[new URL(request.url).pathname] ?? 200; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status }))); + }), + ); + return { layer, requests }; + } + + const settled = ( + mock: ReturnType, + containerIds: ReadonlyArray, + opts: WaitForHealthyServicesOptions = {}, + http: Layer.Layer = unusedHttpClientLayer, + ) => + waitForHealthyServices(mock.spawner, containerIds, { timeoutSeconds: 1, ...opts }).pipe( + Effect.provide(http), + ); + + const timeoutFailure = ( + mock: ReturnType, + containerIds: ReadonlyArray, + opts: WaitForHealthyServicesOptions = {}, + http: Layer.Layer = unusedHttpClientLayer, + ) => + withSilencedStderr( + Effect.gen(function* () { + const fiber = yield* settled(mock, containerIds, opts, http).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }), + ); + + it.effect("names the probe's OCI error and the exec check when no fallback is configured", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + + const error = yield* timeoutFailure(mock, [STUDIO]); + + const reason = `container is not ready: unhealthy (last health check exit code -1: ${EXEC_FAILURE_OUTPUT})`; + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(error.unhealthy).toEqual([{ containerId: STUDIO, reason }]); + expect(error.message).toBe(`${STUDIO} ${reason}`); + expect(error.suggestion).toBe( + [ + `Docker could not run the health check inside ${STUDIO} ("OCI runtime exec failed"), so it cannot report them healthy.`, + "This happens when Docker runs nested or sandboxed.", + `Check with: docker exec ${STUDIO} true`, + ].join("\n"), + ); + }), + ); + + it.effect("names an ordinary failing probe without the exec-unavailable suggestion", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => orderlyUnhealthy); + + const error = yield* timeoutFailure(mock, [STUDIO]); + + expect(error.message).toBe( + `${STUDIO} container is not ready: unhealthy (last health check exit code 1: pg_isready: no response)`, + ); + expect(error.suggestion).toBeUndefined(); + }), + ); + + it.effect("still fails a probe timeout (exit -1) when a running fallback is configured", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => probeTimedOut); + + const error = yield* timeoutFailure(mock, [STUDIO], { + execFallbacks: runningFallback(STUDIO), + }); + + expect(error.message).toBe( + `${STUDIO} container is not ready: unhealthy (last health check exit code -1: Health check exceeded timeout (2s))`, + ); + expect(error.suggestion).toBeUndefined(); + }), + ); + + it.effect("still fails a restarting container whose last probe was an exec failure", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => + inspectedState({ + status: "restarting", + health: "unhealthy", + probes: [{ exitCode: -1, output: EXEC_FAILURE_OUTPUT }], + }), + ); + + const error = yield* timeoutFailure(mock, [STUDIO], { + execFallbacks: runningFallback(STUDIO), + }); + + expect(error.message).toContain(`${STUDIO} container is not ready: unhealthy (last health`); + expect(error.suggestion).toBeUndefined(); + }), + ); + + it.effect( + "accepts an exec-failed container on a running fallback and reports it as assumed", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + + const result = yield* settled(mock, [STUDIO], { execFallbacks: runningFallback(STUDIO) }); + + expect(result).toEqual({ execUnavailable: [STUDIO], readinessAssumed: [STUDIO] }); + }), + ); + + it.effect("verifies an exec-failed container through the gateway with an authenticated GET", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + const gateway = recordingGateway(); + + const result = yield* settled( + mock, + [STORAGE], + { execFallbacks: gatewayFallback(STORAGE, STORAGE_STATUS_PATH) }, + gateway.layer, + ); + + expect(result).toEqual({ execUnavailable: [STORAGE], readinessAssumed: [] }); + expect(gateway.requests).toEqual([ + { + method: "GET", + url: `${GATEWAY_URL}${STORAGE_STATUS_PATH}`, + headers: expect.objectContaining({ apikey: "sb_secret_local" }), + }, + ]); + }), + ); + + for (const status of [503, 404]) { + it.effect(`fails with the gateway's ${status} and the exec suggestion when it is not 200`, () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + const gateway = recordingGateway({ [STORAGE_STATUS_PATH]: status }); + + const error = yield* timeoutFailure( + mock, + [STORAGE], + { execFallbacks: gatewayFallback(STORAGE, STORAGE_STATUS_PATH) }, + gateway.layer, + ); + + expect(error.unhealthy).toEqual([ + { + containerId: STORAGE, + reason: `container is not ready: ${STORAGE_STATUS_PATH}: unexpected status ${status}`, + }, + ]); + expect(error.suggestion).toContain(`Check with: docker exec ${STORAGE} true`); + }), + ); + } + + it.effect("accepts the gateway's 404 as proof that an exec-failed Kong is up", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + const gateway = recordingGateway({ "/": 404 }); + + const result = yield* settled( + mock, + [KONG], + { execFallbacks: reachableFallback(KONG) }, + gateway.layer, + ); + + expect(result).toEqual({ execUnavailable: [KONG], readinessAssumed: [] }); + expect(gateway.requests).toEqual([ + { + method: "GET", + url: `${GATEWAY_URL}/`, + headers: expect.objectContaining({ apikey: "sb_secret_local" }), + }, + ]); + }), + ); + + it.effect("fails an exec-failed Kong whose gateway does not answer", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + const unreachable = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connect ECONNREFUSED"), + }), + }), + ), + ), + ); + + const error = yield* timeoutFailure( + mock, + [KONG], + { execFallbacks: reachableFallback(KONG) }, + unreachable, + ); + + expect(error.unhealthy).toEqual([ + { + containerId: KONG, + reason: `container is not ready: HttpClientError: Transport error (GET ${GATEWAY_URL}/)`, + }, + ]); + expect(error.suggestion).toContain(`Check with: docker exec ${KONG} true`); + }), + ); + + it.effect("fails a gateway GET whose body never completes once one probe timeout elapses", () => + withSilencedStderr( + Effect.gen(function* () { + const mock = mockHealthSpawner(() => EXEC_FAILED_STATE); + const stalledBody = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start: (controller) => controller.enqueue(new TextEncoder().encode("{")), + }), + { status: 200 }, + ), + ), + ), + ), + ); + + const fiber = yield* settled( + mock, + [STORAGE], + { timeoutSeconds: 0, execFallbacks: gatewayFallback(STORAGE, STORAGE_STATUS_PATH) }, + stalledBody, + ).pipe(Effect.forkChild({ startImmediately: true })); + for (let second = 0; second < 9; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAtNineSeconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(pendingAtNineSeconds).toBeUndefined(); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { + containerId: STORAGE, + reason: expect.stringMatching( + /^container is not ready: \/storage\/v1\/status: TimeoutError/u, + ), + }, + ]); + }), + ), + ); + + it.effect("joins the exec-format and exec-unavailable advice for different containers", () => + Effect.gen(function* () { + const broken = "supabase_inbucket_proj"; + const mock = mockHealthSpawner( + (containerId) => (containerId === broken ? notRunning : EXEC_FAILED_STATE), + { [broken]: ["exec /mailpit: exec format error\n"] }, + ); + + const error = yield* timeoutFailure(mock, [broken, STUDIO], { + images: new Map([[broken, "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + }); + + const suggestion = error.suggestion ?? ""; + expect(suggestion).toContain(`${broken}'s image public.ecr.aws/supabase/mailpit:v1.30.2`); + expect(suggestion).toContain( + `architecture.\n\nDocker could not run the health check inside ${STUDIO} (`, + ); + expect(suggestion).not.toContain(`inside ${broken}`); + }), + ); + + it.effect("does not blame exec for an ordinary failure beside an accepted exec-failed one", () => + Effect.gen(function* () { + const mock = mockHealthSpawner((containerId) => + containerId === STUDIO ? EXEC_FAILED_STATE : orderlyUnhealthy, + ); + + const error = yield* timeoutFailure(mock, [STUDIO, STORAGE], { + execFallbacks: runningFallback(STUDIO), + }); + + expect(error.unhealthy.map((failure) => failure.containerId)).toEqual([STORAGE]); + expect(error.suggestion).toBeUndefined(); + }), + ); + + it.effect("reports nothing as exec-unavailable while Docker's health check works", () => + Effect.gen(function* () { + const orderlyStarting = inspectedState({ + health: "starting", + probes: [{ exitCode: 1, output: "pg_isready: no response" }], + }); + const mock = mockHealthSpawner((_id, callIndex) => + callIndex === 0 ? orderlyStarting : runningHealthy, + ); + + const fiber = yield* settled(mock, [STUDIO], { + execFallbacks: runningFallback(STUDIO), + }).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 seconds"); + const result = yield* Fiber.join(fiber); + + expect(result).toEqual({ execUnavailable: [], readinessAssumed: [] }); + }), + ); +}); + +describe("healthCheckExecUnavailableWarning", () => { + it("names every container whose readiness was not verified", () => { + const warning = healthCheckExecUnavailableWarning([ + "supabase_realtime_proj", + "supabase_studio_proj", + ]); + + expect(warning).toContain("WARNING:"); + expect(warning).toContain("(OCI runtime exec failed)"); + expect(warning).toContain( + "Readiness of supabase_realtime_proj, supabase_studio_proj was not verified.", + ); + expect(warning.endsWith("\n")).toBe(true); + }); + + it("says readiness was checked from the host when every container was verified", () => { + const warning = healthCheckExecUnavailableWarning([]); + + expect(warning).toContain("Readiness was checked from the host instead."); + expect(warning).not.toContain("was not verified"); + }); +}); + +describe("awaitDockerHealthSettled", () => { + const DB = "supabase_db_proj"; + + const run = (mock: ReturnType) => + Effect.gen(function* () { + const fiber = yield* awaitDockerHealthSettled(mock.spawner, DB).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + return yield* Fiber.await(fiber); + }); + + const settlesImmediately: ReadonlyArray = [ + ["Docker already reports it healthy", runningHealthy], + ["its health check cannot run on this host", EXEC_FAILED_STATE], + [ + "an exec failure is recorded while Docker still reports starting", + inspectedState({ + health: "starting", + probes: [{ exitCode: -1, output: EXEC_FAILURE_OUTPUT }], + }), + ], + ["it is no longer running", notRunning], + [ + "Docker reports it unhealthy", + inspectedState({ health: "unhealthy", probes: [{ exitCode: 1, output: "no response" }] }), + ], + ["it has no health check", inspectedState()], + ]; + for (const [scenario, state] of settlesImmediately) { + it.effect(`returns after a single inspect when ${scenario}`, () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => state); + + const exit = yield* run(mock); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(1); + }), + ); + } + + it.effect("succeeds without retrying when the inspect itself fails", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningStarting, {}, { inspectFails: true }); + + const exit = yield* run(mock); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(1); + }), + ); + + it.effect("keeps polling while Docker reports starting and returns once it settles", () => + Effect.gen(function* () { + const mock = mockHealthSpawner((_id, callIndex) => + callIndex < 2 ? runningStarting : runningHealthy, + ); + + const fiber = yield* awaitDockerHealthSettled(mock.spawner, DB).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(3); + }), + ); + + it.effect("gives up without failing after 15 seconds of starting, and stops polling", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningStarting); + + const fiber = yield* awaitDockerHealthSettled(mock.spawner, DB).pipe( + Effect.forkChild({ startImmediately: true }), + ); + for (let second = 0; second < 14; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAt14Seconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + const pollsAtCap = inspectCalls(mock).length; + yield* TestClock.adjust("10 seconds"); + + expect(pendingAt14Seconds).toBeUndefined(); + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(pollsAtCap); + }), + ); +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts new file mode 100644 index 0000000000..3cb27f822b --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts @@ -0,0 +1,202 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/process"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; +import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { ConfigEnvPins } from "../../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../../tests/helpers/config-goldens.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { ExperimentalFlag } from "../global-flags.ts"; +import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const GOLDEN_DIR = "./testdata/config-precedence/local-container-inputs"; + +const BASE_CONFIG = `project_id = "golden-shadow" + +[api] +port = 55321 +schemas = ["public", "base_api"] + +[db] +port = 55322 + +[auth] +site_url = "http://base.example.com" +enable_signup = true + +[realtime] +enabled = true + +[storage] +enabled = true + +[experimental.pgdelta] +enabled = false +`; + +const REMOTE_BLOCK = ` +[remotes.prod] +project_id = "${TARGET_REF}" + +[remotes.prod.api] +schemas = ["public", "remote_api"] + +[remotes.prod.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.prod.db] +port = 58322 +`; + +const SHELL_ENV = { + SUPABASE_DB_PORT: "56322", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_REALTIME_ENABLED: "false", + SUPABASE_STORAGE_ENABLED: "false", +}; + +const DOTENV = [ + "SUPABASE_DB_PORT=57322", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_AUTH_SITE_URL=http://dotenv.example.com", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_REALTIME_ENABLED=false", + "SUPABASE_STORAGE_ENABLED=false", + "", +].join("\n"); + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "no-overrides", + name: "shadow container inputs for a linked ref with no overrides use the base config", + config: BASE_CONFIG, + }, + { + golden: "matched-remote", + name: "shadow container inputs apply a matched remote's port, schemas and auth settings", + config: BASE_CONFIG + REMOTE_BLOCK, + }, + { + golden: "shell-env", + name: "shadow container inputs follow shell env over base config", + config: BASE_CONFIG, + shellEnv: SHELL_ENV, + }, + { + golden: "project-dotenv", + name: "shadow container inputs follow supabase/.env over base config", + config: BASE_CONFIG, + dotenv: DOTENV, + }, + { + golden: "matched-remote-with-project-id-env", + name: "SUPABASE_PROJECT_ID beats a matched remote's project_id for the shadow container naming", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + }, + { + golden: "matched-remote-with-shell-env", + name: "shell env beats a matched remote in shadow container inputs while unset keys follow the remote", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: SHELL_ENV, + }, +]; + +const configEnvOnly = (env: Readonly>) => + Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SUPABASE_"))); + +const tempRoot = useTempWorkdir("container-inputs-golden-"); + +describe("local container inputs precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.live(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(fixture.shellEnv ?? {}); + + const layer = Layer.mergeAll( + BunServices.layer, + cliConfigValuesTestLayer, + Layer.succeed(ConfigEnvPins, fixture.shellEnv ?? {}), + mockRuntimeInfo({ platform: "linux" }), + Layer.succeed(CliArgs, { args: ["db", "diff"] }), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), + ), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => + value === undefined ? [] : [[name, value]], + ), + ), + { preserveEmptyStrings: true }, + ), + ), + ); + const spawner = ChildProcessSpawner.make(() => + Effect.die("the container inputs prelude must not spawn a process"), + ); + + const inputs = yield* buildLocalDbContainerInputs( + spawner, + workdir, + Option.none(), + "linux", + false, + TARGET_REF, + ).pipe(Effect.provide(layer)); + + const { context, setup, ...rest } = inputs; + const golden = { + ...rest, + appliedRemote: Option.getOrUndefined(context.resolvedConfig.appliedRemote), + projectEnvValues: configEnvOnly(context.projectEnvValues), + projectId: context.projectId, + setup: { + ...setup, + jwks: undefined, + projectEnvValues: configEnvOnly(setup.projectEnvValues ?? {}), + }, + }; + const goldenText = yield* goldenJson(golden, { [workdir]: "" }); + yield* Effect.promise(() => + ctx.expect(goldenText).toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index ebf52e7904..6986dff839 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -13,6 +13,7 @@ import { HttpClient } from "effect/http"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import type { GlobalFlag } from "effect/cli"; +import type { CliConfigValues } from "../../config/cli-config-values.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { resolveExperimentalWithProjectEnv } from "../global-flags.ts"; @@ -20,7 +21,7 @@ import { DbConfigLoadError } from "../db-config.errors.ts"; import { InvalidServiceVersionTagError } from "../service-version-overrides.ts"; import { localDbContainerId } from "../docker-ids.ts"; import { resolveDockerNetworkMode } from "../../shared/functions/functions-docker.ts"; -import { viperEnvStringWithProjectFallback } from "../viper-env.ts"; +import { supabaseEnvStringWithProjectFallback } from "../supabase-env.ts"; import { isBitbucketPipeline } from "../bitbucket-pipeline.ts"; import { resolveAuthExternalUrl, @@ -81,15 +82,10 @@ export const buildLocalDbContainerInputs = ( // so the shadow's container-spec fields reflect the matching `[remotes.]` override. // `db start`/`db reset` never pass this. projectRef?: string, - // Which keys the caller's own `readDbToml(..., ref)` read set from the matched remote block, - // so a remote-set field isn't overridden again by a conflicting `SUPABASE_*` env var. - // `db start`/`db reset` never pass a `projectRef`, so they never pass this either. - remoteOverrideKeys?: ReadonlySet, // `db start`'s handler already loads a {@link LocalProjectContext} before calling this // function (to validate config ahead of its own "already running" short-circuit). When - // provided, this function skips its own reload — `@supabase/config`'s `loadCliConfig` prints - // deprecated-config WARN lines to stderr, so reloading would print each warning twice. Must - // correspond to the same `workdir`/`projectRef` this call would otherwise use. + // provided, this function skips its own load. Must correspond to the same `workdir`/`projectRef` + // this call would otherwise use. preloadedContext?: LocalProjectContext, ): Effect.Effect< LocalDbContainerInputs, @@ -101,6 +97,7 @@ export const buildLocalDbContainerInputs = ( | GlobalFlag.Setting.Identifier<"experimental"> | CliArgs | HttpClient.HttpClient + | CliConfigValues > => Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; @@ -110,33 +107,25 @@ export const buildLocalDbContainerInputs = ( const context = preloadedContext ?? (yield* loadLocalProjectContext(workdir, mapError, projectRef)); - const { config, projectEnvValues, loaded, hostname, projectId } = context; + const { config, projectEnvValues, resolvedConfig, hostname, projectId } = context; + const document = resolvedConfig.loaded.document ?? {}; const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); - const values = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - config, - hostname, - workdir, - projectEnvValues, - loaded?.document, - remoteOverrideKeys, - ), - catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), - }); + const values = yield* resolveLocalConfigValues(config, hostname, workdir, document).pipe( + Effect.mapError((cause) => mapError(cause.message)), + ); const bootstrapConfig = yield* resolveDbBootstrapConfig( fs, path, - { config, projectEnvValues, workdir, remoteOverrideKeys }, + { config, workdir }, mapError, ); // See {@link resolveDockerNetworkMode} for the full flag/env/fallback precedence. const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: yield* viperEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", projectEnvValues, ), @@ -160,11 +149,7 @@ export const buildLocalDbContainerInputs = ( port: values.dbPort, major_version: bootstrapConfig.majorVersion, orioledb_version: bootstrapConfig.orioledbVersion, - settings: resolveDbSettingsEnvOverrides( - config.db.settings, - projectEnvValues, - remoteOverrideKeys, - ), + settings: resolveDbSettingsEnvOverrides(config.db.settings), }, experimental: { ...config.experimental, @@ -215,22 +200,14 @@ export const buildLocalDbContainerInputs = ( dbUrl: values.dbUrl, jwtSecret: values.jwtSecret, // Lazy: only evaluated when `runFreshDbSetup` reaches realtime setup and it's enabled. - jwks: resolveLocalJwks( - config, - workdir, - values.jwtSecret, - projectEnvValues, - remoteOverrideKeys, - ).pipe( + jwks: resolveLocalJwks(config, workdir, values.jwtSecret).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), Effect.mapError((cause) => mapError(cause.message)), ), apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl( - loaded?.document, - projectEnvValues, - remoteOverrideKeys, - ), + authExternalUrl: resolveAuthExternalUrl(document), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts index 7bd9526ee1..c04f53fada 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts @@ -1,20 +1,19 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import http from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunHttpServer, BunServices, BunSocketServer } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schedule, Stream } from "effect"; +import { HttpServerRequest, HttpServerResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockContainerCliSpawner } from "../../../tests/helpers/local-reset.ts"; -import { DebugLogger } from "../debug-logger.service.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { runtimeInfoLayer } from "../../shared/runtime/runtime-info.layer.ts"; import { LocalDockerEngine, dockerEndpointSocketPath, isLocalDbRunning, + localDbState, localDockerEngineLayer, } from "./local-db-running.ts"; @@ -41,78 +40,95 @@ function withDockerHost( endpoint: string, effect: Effect.Effect, ): Effect.Effect { - return Effect.suspend(() => { - const previous = process.env["DOCKER_HOST"]; - process.env["DOCKER_HOST"] = endpoint; - return effect.pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ preserveEmptyStrings: true }), - ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["DOCKER_HOST"]; - else process.env["DOCKER_HOST"] = previous; - }), - ), - ); - }); + return effect.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { DOCKER_HOST: endpoint }, preserveEmptyStrings: true }), + ), + ); } const ENGINE_IDENTITY_HEADERS = { "api-version": "1.55" }; -const makeEngineServer = (respond: (req: http.IncomingMessage, res: http.ServerResponse) => void) => - Effect.acquireRelease( - Effect.callback<{ - readonly socketPath: string; - readonly requestedUrls: Array; - readonly server: http.Server; - readonly dir: string; - }>((resume) => { - let settled = false; - const dir = mkdtempSync(join(tmpdir(), "ldbeng-")); - const socketPath = join(dir, "d.sock"); - const requestedUrls: Array = []; - const server = http.createServer((req, res) => { - requestedUrls.push(req.url ?? ""); - respond(req, res); - }); - server.once("error", (cause) => { - if (settled) return; - settled = true; - rmSync(dir, { recursive: true, force: true }); - resume(Effect.die(cause)); - }); - server.listen(socketPath, () => { - if (settled) return; - settled = true; - resume(Effect.succeed({ socketPath, requestedUrls, server, dir })); - }); - return Effect.sync(() => { - settled = true; - server.close(); - rmSync(dir, { recursive: true, force: true }); - }); - }), - ({ dir, server }) => - Effect.callback((resume) => { - server.closeAllConnections?.(); - server.close(() => { - rmSync(dir, { recursive: true, force: true }); - resume(Effect.void); - }); +const engineJson = (status: number, body: string) => + HttpServerResponse.text(body, { + status, + headers: ENGINE_IDENTITY_HEADERS, + contentType: "application/json", + }); + +const makeEngineServer = ( + respond: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ldbeng-" }); + const socketPath = path.join(dir, "d.sock"); + const requestedUrls: Array = []; + const server = yield* BunHttpServer.make({ unix: socketPath }); + yield* server.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + requestedUrls.push(request.url); + return yield* respond(request); }), - ); + ); + return { socketPath, requestedUrls }; + }); + +/* + * A raw-socket peer for wire shapes an HTTP server will not produce: it reads + * the request head, writes `reply` verbatim, then closes the connection. With + * no `reply` it holds the connection open without answering. + */ +const makeRawEngineServer = (reply: string | undefined) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ldbraw-" }); + const socketPath = path.join(dir, "d.sock"); + const server = yield* BunSocketServer.make({ path: socketPath }); + yield* server + .run((socket) => + Effect.scoped( + Effect.gen(function* () { + const reader = yield* socket.reader; + const writer = yield* socket.writer; + const decoder = new TextDecoder(); + let head = ""; + while (!head.includes("\r\n\r\n")) { + for (const chunk of yield* reader.pull) { + head += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + } + } + if (reply === undefined) return yield* Effect.never; + yield* writer.write(reply); + }), + ), + ) + .pipe(Effect.forkScoped); + return { socketPath }; + }); + +const missingSocketEndpoint = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ldbgone-" }); + return `unix://${path.join(dir, "never-created.sock")}`; +}); const localDockerEngineTestLayer = localDockerEngineLayer.pipe( Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), ); -const engineContainerExists = (containerId: string) => +const engineInspectContainer = (containerId: string) => Effect.gen(function* () { const engine = yield* LocalDockerEngine; - return yield* engine.containerExists(containerId); + return yield* engine.inspectContainer(containerId); }).pipe(Effect.provide(localDockerEngineTestLayer)); describe("LocalDockerEngine (direct Engine-API transport)", () => { @@ -120,93 +136,134 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { it.live("answers present for an Engine 200 inspect payload on the documented route", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end(JSON.stringify({ Id: "abc123", State: { Status: "created" } })); - }); + const engine = yield* makeEngineServer(() => + Effect.succeed( + engineJson( + 200, + JSON.stringify({ Id: "abc123", State: { Running: true, Status: "running" } }), + ), + ), + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); - expect(answer).toEqual(Option.some(true)); + expect(answer).toEqual(Option.some({ exists: true, running: true, status: "running" })); expect(engine.requestedUrls).toEqual(["/containers/supabase_db_engine-probe/json"]); }), - ), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.live.each([ + ["a stopped container", { Running: false, Status: "exited" }, false, "exited"], + ["a created container", { Running: false, Status: "created" }, false, "created"], + ["a running container", { Running: true, Status: "running" }, true, "running"], + ["a missing State", undefined, true, ""], + ["a non-object State", "exited", true, ""], + ] as const)( + "reports %s from the Engine inspect payload's State", + ([, state, running, status]) => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer(() => + Effect.succeed( + engineJson( + 200, + JSON.stringify({ + Id: "abc123", + ...(state === undefined ? {} : { State: state }), + }), + ), + ), + ); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineInspectContainer("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.some({ exists: true, running, status })); + }), + ).pipe(Effect.provide(BunServices.layer)), ); it.live("answers absent for an Engine 404", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end(JSON.stringify({ message: "No such container" })); - }); + const engine = yield* makeEngineServer(() => + Effect.succeed(engineJson(404, JSON.stringify({ message: "No such container" }))), + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); - expect(answer).toEqual(Option.some(false)); + expect(answer).toEqual(Option.some({ exists: false })); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live("gives no answer for a responder that does not identify as a Docker engine", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((req, res) => { - if (req.url?.includes("present") === true) { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ Id: "not-an-engine" })); - return; + const engine = yield* makeEngineServer((request) => { + if (request.url.includes("present")) { + return Effect.succeed( + HttpServerResponse.text(JSON.stringify({ Id: "not-an-engine" }), { + status: 200, + contentType: "application/json", + }), + ); } - if (req.url?.includes("server-header") === true) { - res.writeHead(404, { server: "Docker/28.5.2 (linux)" }); - res.end(JSON.stringify({ message: "No such container" })); - return; + if (request.url.includes("server-header")) { + return Effect.succeed( + HttpServerResponse.text(JSON.stringify({ message: "No such container" }), { + status: 404, + headers: { server: "Docker/28.5.2 (linux)" }, + }), + ); } - res.writeHead(404); - res.end(); + return Effect.succeed(HttpServerResponse.empty({ status: 404 })); }); const present = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("present"), + engineInspectContainer("present"), ); const absent = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("absent"), + engineInspectContainer("absent"), ); const viaServerHeader = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("server-header"), + engineInspectContainer("server-header"), ); expect(present).toEqual(Option.none()); expect(absent).toEqual(Option.none()); - expect(viaServerHeader).toEqual(Option.some(false)); + expect(viaServerHeader).toEqual(Option.some({ exists: false })); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live("gives no answer for an empty or malformed Engine 200 body", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((req, res) => { - res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end( - req.url?.includes("empty") === true ? "" : JSON.stringify(["not", "an", "object"]), - ); - }); + const engine = yield* makeEngineServer((request) => + Effect.succeed( + engineJson( + 200, + request.url.includes("empty") ? "" : JSON.stringify(["not", "an", "object"]), + ), + ), + ); const empty = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("empty"), + engineInspectContainer("empty"), ); const malformed = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("malformed"), + engineInspectContainer("malformed"), ); expect(empty).toEqual(Option.none()); expect(malformed).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live( @@ -214,20 +271,16 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(500, { - "content-type": "application/json", - ...ENGINE_IDENTITY_HEADERS, - }); - res.end(JSON.stringify({ message: "layer store corrupted" })); - }); + const engine = yield* makeEngineServer(() => + Effect.succeed(engineJson(500, JSON.stringify({ message: "layer store corrupted" }))), + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live( @@ -235,52 +288,46 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer(() => {}); + const engine = yield* makeRawEngineServer(undefined); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), 15_000, ); it.live("gives no answer for an oversized body", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end(`{"Id":"${"a".repeat(80 * 1024)}"}`); - }); + const engine = yield* makeEngineServer(() => + Effect.succeed(engineJson(200, `{"Id":"${"a".repeat(80 * 1024)}"}`)), + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live("gives no answer when the peer resets after the headers", () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(200, { - "content-type": "application/json", - "content-length": "1000", - ...ENGINE_IDENTITY_HEADERS, - }); - res.write('{"Id":'); - res.socket?.destroy(); - }); + const engine = yield* makeRawEngineServer( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 1000\r\n" + + 'api-version: 1.55\r\n\r\n{"Id":', + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), ); it.live("traces the request, the decline, and the fallback through DebugLogger", () => { @@ -297,14 +344,13 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const traced = Effect.gen(function* () { const engine = yield* LocalDockerEngine; - return yield* engine.containerExists("supabase_db_engine-probe"); + return yield* engine.inspectContainer("supabase_db_engine-probe"); }).pipe(Effect.provide(localDockerEngineTestLayer.pipe(Layer.provide(recorder)))); return Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end(JSON.stringify({ message: "No such container" })); - }); + const engine = yield* makeEngineServer(() => + Effect.succeed(engineJson(404, JSON.stringify({ message: "No such container" }))), + ); yield* withDockerHost(`unix://${engine.socketPath}`, traced); expect( lines.some( @@ -328,17 +374,12 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { yield* withDockerHost("deploy:hunter4@10.0.0.5:2376", traced); expect(lines.some((l) => l.includes("not directly addressable (no scheme)"))).toBe(true); expect(lines.some((l) => l.includes("hunter4") || l.includes("deploy"))).toBe(false); - const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); - yield* withDockerHost(`unix://${join(missingDir, "never-created.sock")}`, traced).pipe( - Effect.ensuring( - Effect.sync(() => rmSync(missingDir, { recursive: true, force: true })), - ), - ); + yield* withDockerHost(yield* missingSocketEndpoint, traced); expect( lines.some((l) => l.startsWith("debug:") && l.includes("no definitive Engine answer")), ).toBe(true); }), - ); + ).pipe(Effect.provide(BunServices.layer)); }); it.live( @@ -346,46 +387,46 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { () => Effect.scoped( Effect.gen(function* () { - const engine = yield* makeEngineServer((_req, res) => { - res.writeHead(200, { - "content-type": "application/json", - ...ENGINE_IDENTITY_HEADERS, - }); - const drip = setInterval(() => { - res.write("a"); - }, 500); - res.on("close", () => { - clearInterval(drip); - }); - }); + const drip = new TextEncoder().encode("a"); + const engine = yield* makeEngineServer(() => + Effect.succeed( + HttpServerResponse.stream( + Stream.fromSchedule(Schedule.spaced("500 millis")).pipe(Stream.as(drip)), + { + status: 200, + headers: ENGINE_IDENTITY_HEADERS, + contentType: "application/json", + }, + ), + ), + ); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), - ), + ).pipe(Effect.provide(BunServices.layer)), 15_000, ); }); - it.live("gives no answer when the local socket cannot be dialed", () => { - const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); - return withDockerHost( - `unix://${join(missingDir, "never-created.sock")}`, - engineContainerExists("supabase_db_engine-probe"), - ).pipe( - Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))), - Effect.map((answer) => { + it.live("gives no answer when the local socket cannot be dialed", () => + Effect.scoped( + Effect.gen(function* () { + const answer = yield* withDockerHost( + yield* missingSocketEndpoint, + engineInspectContainer("supabase_db_engine-probe"), + ); expect(answer).toEqual(Option.none()); }), - ); - }); + ).pipe(Effect.provide(BunServices.layer)), + ); it.live("gives no answer for an endpoint the transport cannot address (ssh)", () => withDockerHost( "ssh://user@remote-host", - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ).pipe( Effect.map((answer) => { expect(answer).toEqual(Option.none()); @@ -395,26 +436,76 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); describe("isLocalDbRunning", () => { - const probe = (spawnerLayer: Layer.Layer) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workdir = mkdtempSync(join(tmpdir(), "ldbrun-")); - return yield* isLocalDbRunning(spawner, fs, path, workdir, "engine-probe").pipe( - Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), - ); - }).pipe(Effect.provide(spawnerLayer), Effect.provide(BunServices.layer)); + const probe = ( + spawnerLayer: Layer.Layer, + options: { readonly toml?: string; readonly env?: Readonly> } = { + toml: 'project_id = "engine-probe"\n', + }, + ) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "ldbrun-" }); + if (options.toml !== undefined) { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), options.toml); + } + return yield* isLocalDbRunning(spawner, fs, path, workdir); + }), + ).pipe( + Effect.provide( + configValuesLayer({ output: mockOutput().layer, env: options.env }).pipe( + Layer.provideMerge(Layer.mergeAll(BunServices.layer, spawnerLayer)), + ), + ), + ); + + it.live("probes the container named by config.toml's project_id", () => { + const asked: Array = []; + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer, { toml: 'project_id = "tomlproj"\n' }).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: (containerId) => + Effect.sync(() => { + asked.push(containerId); + }).pipe(Effect.as(Option.some({ exists: true, running: true, status: "running" }))), + }), + Effect.map(() => { + expect(asked).toEqual(["supabase_db_tomlproj"]); + }), + ); + }); + + it.live("probes the container named by SUPABASE_PROJECT_ID over config.toml's project_id", () => { + const asked: Array = []; + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer, { + toml: 'project_id = "tomlproj"\n', + env: { SUPABASE_PROJECT_ID: "envproj" }, + }).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: (containerId) => + Effect.sync(() => { + asked.push(containerId); + }).pipe(Effect.as(Option.some({ exists: true, running: true, status: "running" }))), + }), + Effect.map(() => { + expect(asked).toEqual(["supabase_db_envproj"]); + }), + ); + }); it.live("trusts a definitive Engine answer without spawning the container CLI", () => { const asked: Array = []; const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: (containerId) => + inspectContainer: (containerId) => Effect.sync(() => { asked.push(containerId); - }).pipe(Effect.as(Option.some(true))), + }).pipe(Effect.as(Option.some({ exists: true, running: true, status: "running" }))), }), Effect.map((running) => { expect(running).toBe(true); @@ -424,11 +515,39 @@ describe("isLocalDbRunning", () => { ); }); + it.live("still reports a stopped container as present, from the Engine answer", () => { + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => + Effect.succeed(Option.some({ exists: true, running: false, status: "exited" })), + }), + Effect.map((exists) => { + expect(exists).toBe(true); + expect(mock.spawned).toEqual([]); + }), + ); + }); + + it.live("still reports a stopped container as present, from the container CLI stdout", () => { + const mock = mockContainerCliSpawner(() => ({ + stdout: [JSON.stringify([{ State: { Running: false, Status: "exited" } }])], + })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => Effect.succeed(Option.none()), + }), + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }); + it.live("trusts a definitive Engine 404 without spawning the container CLI", () => { const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.some(false)), + inspectContainer: () => Effect.succeed(Option.some({ exists: false })), }), Effect.map((running) => { expect(running).toBe(false); @@ -444,7 +563,7 @@ describe("isLocalDbRunning", () => { })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.none()), + inspectContainer: () => Effect.succeed(Option.none()), }), Effect.map((running) => { expect(running).toBe(false); @@ -462,19 +581,104 @@ describe("isLocalDbRunning", () => { exitCode: 1, stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"], })); - const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); - return withDockerHost( - `unix://${join(missingDir, "never-created.sock")}`, - probe(mock.layer).pipe(Effect.provide(localDockerEngineTestLayer)), - ).pipe( - Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))), - Effect.map((running) => { + return Effect.scoped( + Effect.gen(function* () { + const running = yield* withDockerHost( + yield* missingSocketEndpoint, + probe(mock.layer).pipe(Effect.provide(localDockerEngineTestLayer)), + ); expect(running).toBe(false); expect(mock.spawned.map((s) => s.args)).toEqual([ ["container", "inspect", "supabase_db_engine-probe"], ]); }), + ).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); + +describe("localDbState", () => { + const stateOf = (spawnerLayer: Layer.Layer) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "ldbstate-" }); + return yield* localDbState(spawner, fs, path, workdir, "engine-probe"); + }), + ).pipe( + Effect.provide( + configValuesLayer({ output: mockOutput().layer }).pipe( + Layer.provideMerge(Layer.mergeAll(BunServices.layer, spawnerLayer)), + ), + ), + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => Effect.succeed(Option.none()), + }), + ); + + it.live.each([ + ["a stopped container", { Running: false, Status: "exited" }, false, "exited"], + ["a created container", { Running: false, Status: "created" }, false, "created"], + ["a running container", { Running: true, Status: "running" }, true, "running"], + ] as const)( + "reads %s from the container CLI inspect stdout's State", + ([, state, running, status]) => { + const mock = mockContainerCliSpawner(() => ({ + stdout: [JSON.stringify([{ Id: "abc123", State: state }])], + })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: true, running, status }); + expect(mock.spawned.map((s) => s.args)).toEqual([ + ["container", "inspect", "supabase_db_engine-probe"], + ]); + }), ); }, ); + + it.live.each([ + ["empty", ""], + ["not JSON", "not json"], + ["a JSON object rather than an array", '{"State":{"Running":false,"Status":"exited"}}'], + ["an empty array", "[]"], + ["an entry without State", '[{"Id":"abc123"}]'], + ] as const)( + "counts a container whose inspect stdout is %s as running when the exit code is 0", + ([, stdout]) => { + const mock = mockContainerCliSpawner(() => ({ stdout: stdout === "" ? [] : [stdout] })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: true, running: true, status: "" }); + }), + ); + }, + ); + + it.live("answers absent when the container CLI reports no such container", () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"], + })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: false }); + }), + ); + }); + + it.live("fails with the daemon marker when the container CLI cannot reach the daemon", () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Cannot connect to the Docker daemon at unix:///var/run/docker.sock."], + })); + return stateOf(mock.layer).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toMatchObject({ _tag: "LocalDbRunningError", daemonDown: true }); + }), + ); + }); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index 0f8e9a547b..df89a0d462 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -1,6 +1,6 @@ import http from "node:http"; -import { Context, Crypto, Data, Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Context, Crypto, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { @@ -9,12 +9,14 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; -import { isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.ts"; -import { readDbToml } from "../db-config.toml-read.ts"; -import { resolveLocalProjectId, localDbContainerId } from "../docker-ids.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; +import { collectText, isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.ts"; +import { localDbContainerId } from "../docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.ts"; import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-suggest.ts"; import { redactHttpUrl } from "../../auth/http-debug.layer.ts"; -import { DebugLogger } from "../debug-logger.service.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { resolveDockerDaemonEndpoint } from "../hostname.ts"; @@ -37,22 +39,25 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError") } } +/** Whether the local db container exists and, when it does, its Docker `State`. */ +export type LocalDbState = + | { readonly exists: false } + | { readonly exists: true; readonly running: boolean; readonly status: string }; + /** - * Direct Engine-API access to the locally addressable Docker daemon, so - * {@link isLocalDbRunning} does not depend on a spawnable, responsive - * `docker` CLI binary (issue #6110). `containerExists`: `Option.some(true)` — - * an Engine-identified 200 with a valid inspect payload; `Option.some(false)` - * — an Engine-identified 404; `Option.none()` — anything else (non-addressable - * endpoint, transport failure, silent socket, the 5s wall-clock bound - * expiring, non-Engine responder, abnormal - * status, malformed body), telling the caller to fall back to the container - * CLI, which preserves the established wording, daemon-down classification, - * and Podman fallback. + * Direct Engine-API access to the locally addressable Docker daemon, so {@link localDbState} does + * not depend on a spawnable, responsive `docker` CLI binary (issue #6110). `inspectContainer`: + * `Option.some({ exists: false })` — an Engine-identified 404; `Option.some({ exists: true, … })` — + * an Engine-identified 200 with a valid inspect payload; `Option.none()` — anything else + * (non-addressable endpoint, transport failure, silent socket, the 5s wall-clock bound expiring, + * non-Engine responder, abnormal status, malformed body), telling the caller to fall back to the + * container CLI, which preserves the established wording, daemon-down classification, and Podman + * fallback. */ export class LocalDockerEngine extends Context.Service< LocalDockerEngine, { - readonly containerExists: (containerId: string) => Effect.Effect>; + readonly inspectContainer: (containerId: string) => Effect.Effect>; } >()("supabase/cli/LocalDockerEngine") {} @@ -129,6 +134,30 @@ const isEngineResponse = (response: http.IncomingMessage): boolean => response.headers["api-version"] !== undefined || String(response.headers["server"] ?? "").startsWith("Docker/"); +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A missing or garbled `State` counts as running, so only an explicit `Running: false` differs. */ +const existingDbState = (inspect: Readonly>): LocalDbState => { + const state = isRecord(inspect["State"]) ? inspect["State"] : {}; + return { + exists: true, + running: state["Running"] !== false, + status: typeof state["Status"] === "string" ? state["Status"] : "", + }; +}; + +const parseInspectStdout = (stdout: string): LocalDbState => { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + parsed = undefined; + } + const first: unknown = Array.isArray(parsed) ? parsed[0] : undefined; + return existingDbState(isRecord(first) ? first : {}); +}; + /** * `GET /containers//json` over a local socket / named pipe. Total: every * terminal state settles exactly once, and anything that is not an @@ -139,10 +168,10 @@ const isEngineResponse = (response: http.IncomingMessage): boolean => const inspectContainerOverSocket = ( socketPath: string, containerId: string, -): Effect.Effect> => +): Effect.Effect> => Effect.callback((resume, signal) => { let settled = false; - const settle = (result: Option.Option) => { + const settle = (result: Option.Option) => { if (settled) return; settled = true; resume(Effect.succeed(result)); @@ -187,7 +216,7 @@ const inspectContainerOverSocket = ( } const status = response.statusCode ?? 0; if (status === 404) { - settle(Option.some(false)); + settle(Option.some({ exists: false })); return; } if (status !== 200) { @@ -201,11 +230,11 @@ const inspectContainerOverSocket = ( } catch { payload = undefined; } - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + if (!isRecord(payload)) { settleNone(); return; } - settle(Option.some(true)); + settle(Option.some(existingDbState(payload))); }); }, ); @@ -263,7 +292,7 @@ export const localDockerEngineLayer: Layer.Layer< const httpLine = (url: string) => Option.isSome(debugLogger) ? debugLogger.value.http("GET", redactHttpUrl(url)) : Effect.void; return LocalDockerEngine.of({ - containerExists: (containerId) => + inspectContainer: (containerId) => Effect.gen(function* () { const endpoint = yield* resolveEndpoint; const endpointValue = Option.getOrUndefined(endpoint); @@ -272,13 +301,13 @@ export const localDockerEngineLayer: Layer.Layer< if (socketPath === undefined) { return yield* debug( `local db engine probe: endpoint not directly addressable (${describeEndpoint(endpointValue)}) — using the container CLI`, - ).pipe(Effect.as(Option.none())); + ).pipe(Effect.as(Option.none())); } return yield* httpLine(`${endpointValue}/containers/${containerId}/json`).pipe( Effect.andThen(inspectContainerOverSocket(socketPath, containerId)), Effect.timeoutOrElse({ duration: ENGINE_PROBE_DEADLINE_MS, - orElse: () => Effect.succeed(Option.none()), + orElse: () => Effect.succeed(Option.none()), }), Effect.tap((answer) => Option.isNone(answer) @@ -293,66 +322,46 @@ export const localDockerEngineLayer: Layer.Layer< }), ); -const decodeChunks = (chunks: ReadonlyArray): string => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder().decode(bytes); -}; - /** - * Answers whether the local Postgres container exists. Resolves `true` when it exists, `false` - * when it definitively does not, and fails with {@link LocalDbRunningError} on any other inspect + * Resolves the local Postgres container's {@link LocalDbState}: `exists: false` when it + * definitively does not exist, and fails with {@link LocalDbRunningError} on any other inspect * failure (e.g. an unreachable daemon) rather than treating it as "not running". * * Asks the Engine API first ({@link LocalDockerEngine}) so a stalled `docker` binary can't block * the probe, and falls back to the container-CLI spawn (Podman fallback, daemon-down - * classification) only when the Engine gives no definitive answer. `resolveDbToml` is a - * best-effort read: only `projectId` is needed, and an unreadable `.env` falls back to the - * workdir basename. + * classification) only when the Engine gives no definitive answer. The project id is a + * best-effort read of the resolved config unless the caller passes its already-resolved id: an + * unreadable config falls back to the workdir basename. */ -export function isLocalDbRunning( +export function localDbState( spawner: Spawner, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - configuredProjectId: string | undefined, -): Effect.Effect { + resolvedProjectId?: string, +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { - // Config was already validated (and any unresolved-env WARN already printed) by the - // caller; only the resolved projectId matters here. - const tomlProjectId = yield* readDbToml(fs, path, workdir, undefined, { - validate: false, - warnOnUnresolvedEnv: false, - }).pipe( - Effect.map((toml) => toml.projectId), - Effect.orElseSucceed(() => Option.none()), - ); - const projectId = resolveLocalProjectId( - path, - configuredProjectId, - Option.getOrUndefined(tomlProjectId), - workdir, - ); + const values = yield* CliConfigValues; + const projectId = + resolvedProjectId ?? + (yield* values.load({ workdir, projectRef: Option.none() }).pipe( + Effect.flatMap((resolvedConfig) => resolvedConfig.get(CliConfigKeys.projectId)), + Effect.map(({ value }) => value), + Effect.orElseSucceed(() => sanitizeProjectId(path.basename(workdir))), + )); const containerId = localDbContainerId(projectId); // Engine probe first; `Option.none()` falls through to the CLI spawn below. const engine = yield* LocalDockerEngine; - const engineAnswer = yield* engine.containerExists(containerId); + const engineAnswer = yield* engine.inspectContainer(containerId); yield* Effect.annotateCurrentSpan( "probe.method", Option.isSome(engineAnswer) ? "engine" : "cli", ); if (Option.isSome(engineAnswer)) return engineAnswer.value; - // Discard stdout (the inspect JSON) so the unconsumed pipe can never - // deadlock; only the exit code + stderr matter. const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { stdin: "ignore", - stdout: "ignore", + stdout: "pipe", stderr: "pipe", extendEnv: true, }).pipe( @@ -364,11 +373,10 @@ export function isLocalDbRunning( }), ), ); - const stderrChunks: Array = []; - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), + // Drained concurrently: a full, unread pipe would stall the child. + const [stdout, stderrText] = yield* Effect.all( + [collectText(child.stdout), collectText(child.stderr)], + { concurrency: "unbounded" }, ).pipe( Effect.mapError(() => new LocalDbRunningError({ message: "failed to inspect service" })), ); @@ -376,9 +384,9 @@ export function isLocalDbRunning( Effect.map(Number), Effect.mapError(() => new LocalDbRunningError({ message: "failed to inspect service" })), ); - if (inspectExit === 0) return true; + if (inspectExit === 0) return parseInspectStdout(stdout); - const stderr = decodeChunks(stderrChunks).trim(); + const stderr = stderrText.trim(); if (!isContainerNotFoundMessage(stderr)) { // Surface the Docker install hint instead of raw stderr when the daemon is unreachable. const daemonDown = isDockerDaemonUnreachable(stderr); @@ -393,7 +401,31 @@ export function isLocalDbRunning( }), ); } - return false; + return { exists: false } satisfies LocalDbState; }), - ).pipe(Effect.withSpan("LocalDb.isRunning")); + ).pipe( + Effect.tap((state) => + Effect.annotateCurrentSpan({ + "db.exists": state.exists, + ...(state.exists && { "db.running": state.running, "db.status": state.status }), + }), + ), + Effect.withSpan("LocalDb.state"), + ); +} + +/** + * Answers whether the local Postgres container exists, whatever state it is in. Callers that + * need to know it is running use {@link localDbState}. + */ +export function isLocalDbRunning( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + resolvedProjectId?: string, +): Effect.Effect { + return localDbState(spawner, fs, path, workdir, resolvedProjectId).pipe( + Effect.map((state) => state.exists), + ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index afa0317e8d..72a680111e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -18,15 +18,10 @@ import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { collectText, describeContainerCliFailure, spawnContainerCli } from "../container-cli.ts"; import { dockerCopyArchiveIntoContainer } from "./container-lifecycle.ts"; import type { StartContainerSpec } from "./docker-create-args.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** - * PGDATA path inside every `supabase/postgres` image. Hardcoded rather than read from the - * container's own `PGDATA` env, since the tar layout depends on this exact value. - */ -export const PGDATA_PATH = "/var/lib/postgresql/data"; - /** * `docker cp` unpacks an archive's members relative to `dest`, and the export tar has `data/` as * its own top-level member, so the restore target must be PGDATA's parent. A POSIX constant, not @@ -38,7 +33,7 @@ export const PGDATA_PARENT_PATH = "/var/lib/postgresql"; * PGDATA's own directory name — `docker cp :
-` names its members after the source * basename, so `data/` is the export tar's top-level entry. */ -const PGDATA_DIR_NAME = PGDATA_PATH.slice(PGDATA_PARENT_PATH.length + 1); +const PGDATA_DIR_NAME = POSTGRES_DATA_DIR.slice(PGDATA_PARENT_PATH.length + 1); /** * Proves an archive carries an exported cluster: every Postgres data directory has a @@ -128,13 +123,17 @@ export const stampPgDataBaselineMarker = ( ): Effect.Effect => Effect.gen(function* () { const tar = yield* pgDataBaselineMarkerTar(key); - yield* dockerCopyArchiveIntoContainer(spawner, tar, `${containerId}:${PGDATA_PATH}`, (detail) => - pgDataSnapshotUnavailable(`failed to stamp ${PGDATA_BASELINE_MARKER_ENTRY}: ${detail}`), + yield* dockerCopyArchiveIntoContainer( + spawner, + tar, + `${containerId}:${POSTGRES_DATA_DIR}`, + (detail) => + pgDataSnapshotUnavailable(`failed to stamp ${PGDATA_BASELINE_MARKER_ENTRY}: ${detail}`), ); }); /** - * Streams `docker cp :${PGDATA_PATH} -` to a temp file next to `tarPath` and + * Streams `docker cp :${POSTGRES_DATA_DIR} -` to a temp file next to `tarPath` and * `rename`s it into place, so a partially written tar is never visible under the final name; * any failure removes the temp file. The container must already be stopped (callers own the * stop/start), and the temp name is scoped by pid alone, so concurrent exports to the same @@ -155,12 +154,12 @@ export const exportPgDataTar = ( Effect.gen(function* () { const child = yield* spawnContainerCli( spawner, - ["cp", `${containerId}:${PGDATA_PATH}`, "-"], + ["cp", `${containerId}:${POSTGRES_DATA_DIR}`, "-"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, ).pipe( Effect.mapError((cause) => pgDataSnapshotUnavailable( - `failed to export ${PGDATA_PATH}: ${describeContainerCliFailure(cause)}`, + `failed to export ${POSTGRES_DATA_DIR}: ${describeContainerCliFailure(cause)}`, ), ), ); @@ -181,7 +180,7 @@ export const exportPgDataTar = ( ).pipe( Effect.mapError((cause) => pgDataSnapshotUnavailable( - `failed to export ${PGDATA_PATH}: ${describeContainerCliFailure(cause)}`, + `failed to export ${POSTGRES_DATA_DIR}: ${describeContainerCliFailure(cause)}`, ), ), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts index be97398051..ac8db9b61b 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -11,7 +11,7 @@ import type { CliConfig } from "@supabase/config"; import { localDbContainerId } from "../docker-ids.ts"; import { toDockerMountPath } from "../docker-path.ts"; -import { encodeToml } from "../go-output.encoders.ts"; +import { encodeToml } from "../output.encoders.ts"; import { POSTGRES_DEFAULT_ROOT_KEY } from "../local-config-values.ts"; import type { StartContainerSpec } from "./docker-create-args.ts"; import { START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; @@ -25,6 +25,9 @@ import { START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; */ const POSTGRES_PASSWORD = "postgres"; +/** `PGDATA` in every `supabase/postgres` image; the pgdata snapshot tar layout depends on it. */ +export const POSTGRES_DATA_DIR = "/var/lib/postgresql/data"; + /** * In-container path for the pgsodium root key, delivered via * {@link buildPostgresStartContainerSpec}'s `secretFiles` docker cp rather than a heredoc. @@ -309,7 +312,7 @@ export function buildPostgresStartContainerSpec( secretFiles: [{ containerPath: POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }], }), binds: [ - `${containerName}:/var/lib/postgresql/data`, + `${containerName}:${POSTGRES_DATA_DIR}`, // The backup file bind is appended only on the `fromBackup` branch. ...(input.fromBackup === undefined ? [] diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts index f431f2181c..47f7bf755c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts @@ -114,7 +114,7 @@ describe("buildPostgresStartContainerSpec", () => { expect(spec.secretFiles).toBeUndefined(); }); - test("uses a rootKey override instead of the Go default when provided", () => { + test("uses a rootKey override instead of the default when provided", () => { const spec = buildPostgresStartContainerSpec( baseInput({ db: baseDb({ major_version: 17 }), rootKey: "custom-root-key" }), ); @@ -163,7 +163,7 @@ describe("buildPostgresStartContainerSpec", () => { }); }); - test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { + test("OrioleDB branch defaults unset S3 fields to empty strings", () => { const spec = buildPostgresStartContainerSpec( baseInput({ db: baseDb({ orioledb_version: "17.4.1.030" }) }), ); @@ -223,7 +223,7 @@ describe("buildPostgresStartContainerSpec", () => { expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); }); - test("healthcheck matches Go's pg_isready probe", () => { + test("healthcheck uses the pg_isready probe", () => { const spec = buildPostgresStartContainerSpec(baseInput()); expect(spec.healthcheck).toEqual({ test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], @@ -380,7 +380,7 @@ describe("postgresImageVersionTag", () => { expect(postgresImageVersionTag("supabase/postgres:17.4.1.030")).toBe("17.4.1.030"); }); - test("degrades to the whole string when there is no colon at all, matching Go's Image[i+1:] with i=-1", () => { + test("degrades to the whole string when there is no colon at all", () => { expect(postgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); }); }); @@ -451,7 +451,7 @@ describe("buildShadowPostgresContainerSpec", () => { expect(spec.labels).toEqual({}); }); - test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal — the deliberate TS extension the input's own doc describes (Go rejects the toml key at config load and always uses 'postgres')", () => { + test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal", () => { const spec = buildShadowPostgresContainerSpec(baseShadowInput({ password: "hunter2" })); expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts index 18ed9b0dc9..a4f456cd5f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts @@ -24,7 +24,7 @@ describe("buildRealtimeEnv", () => { expect(env["API_JWT_JWKS"]).toBe(base.jwks); }); - test("matches Go's remaining static env values", () => { + test("sets the remaining static env values", () => { const env = buildRealtimeEnv(base); expect(env).toMatchObject({ PORT: "4000", diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index 8e5bd3549c..ce496f87c0 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -2,14 +2,16 @@ * `db reset --local`'s container-recreate composition — not a thin wrapper over * {@link startDatabase}, since the shared `StartDatabase`-equivalent primitive is never called * from a reset either. Reuses the same underlying primitives (`ensureNetwork`, - * `buildPostgresStartContainerSpec`, `createContainer`, `waitForHealthyServices`, + * `buildPostgresStartContainerSpec`, `createContainer`, `waitForPostgresReady`, * `startSetupLocalDatabase`) with a distinct sequence per major version: PG >= 15 recreates the * container/volume/network and re-runs setup; PG <= 14 recreates the two databases in place and * reinitializes the schema. Neither path probes for an existing volume, supports - * `--from-backup`, or rolls back on failure. + * `--from-backup`, or rolls back the database on failure; PG15 stops storage/auth/realtime/pooler + * first and restarts them (+ Kong reload) once at the end, or after a failure unless Docker + * reports the db container missing or stopped. */ -import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; +import { Data, Effect, Exit, Result, Schedule, type FileSystem, type Path } from "effect"; import type * as HttpClient from "effect/http/HttpClient"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; @@ -21,10 +23,12 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; import { isSqlState } from "../connect-errors.ts"; +import { isContainerNotFoundMessage } from "../container-cli.ts"; import { checkDbToml } from "../db-config.toml-read.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; import { DbExecError, type DbConnectError } from "../db-connection.errors.ts"; import { CLI_PROJECT_LABEL } from "../docker-ids.ts"; +import { inspectContainerState } from "../docker-lifecycle.ts"; import type { DockerRun } from "../docker-run.service.ts"; import { migrateAndSeed } from "../migrate-and-seed.ts"; import { formatExecBatchError, type MigrationApplyError } from "../migration-apply.ts"; @@ -44,7 +48,6 @@ import { } from "./container-lifecycle.ts"; import { runFreshDbSetup, - resolveResetSeedConfig, applyApiPrivileges, applyDatabaseWebhooks, initSchema14, @@ -53,7 +56,11 @@ import { type FreshDbSetupInput, type StartSetupLocalDatabaseError, } from "./db-setup.ts"; -import { waitForHealthyServices, type HealthCheckTimeoutError } from "./health-check.ts"; +import { + awaitDockerHealthSettled, + waitForPostgresReady, + type HealthCheckTimeoutError, +} from "./health-check.ts"; import type { ImagePrepullError } from "./image-prepull.ts"; import { startInternalDbPassword } from "./internal-db-connection.ts"; import { @@ -63,10 +70,12 @@ import { import { restartContainer, restartServicesAndReloadKong, + stopSatelliteServices, type ContainerRestartError, type KongReloadError, type RestartServicesError, } from "./restart-services.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -130,8 +139,6 @@ export interface RecreateLocalDatabaseInput { readonly dbHealthTimeoutSeconds: number; /** The resolved reset migration version (`""` for every pending migration). */ readonly version: string; - /** `db reset`'s `--no-seed`/`--sql-paths` — see {@link resolveResetSeedConfig}. */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; /** The same shape `start-database.ts`'s `StartDatabaseInput.setup` uses — hoisted to {@link FreshDbSetupInput}. */ readonly setup: FreshDbSetupInput; } @@ -248,6 +255,8 @@ const resetRecreateDatabases = Effect.fn("DbBootstrap.recreateDatabases")(functi } }); +const STACK_RECOVERY_HINT = "Run supabase stop && supabase start to bring the local stack back."; + /** PG >= 15 recreate path — see this module's own header for the full sequence. */ const recreateLocalDatabase15 = ( spawner: Spawner, @@ -262,10 +271,12 @@ const recreateLocalDatabase15 = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const output = yield* Output; + yield* stopSatelliteServices(spawner, input.projectId); yield* removeContainer(spawner, input.dbContainerId); yield* removeVolume(spawner, input.dbContainerId); @@ -284,13 +295,21 @@ const recreateLocalDatabase15 = ( yield* createContainer(spawner, postgresSpec, input.containerOpts); // Never swallowed — reset has no `--from-backup`-equivalent gate. - yield* waitForHealthyServices(spawner, [postgresSpec.containerName], { - timeoutSeconds: input.dbHealthTimeoutSeconds, - images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), - }); + yield* waitForPostgresReady( + spawner, + postgresSpec.containerName, + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: startInternalDbPassword(input.setup.dbUrl), + database: "postgres", + }, + { timeoutSeconds: input.dbHealthTimeoutSeconds, image: resolvedPostgresImage }, + ); // No fresh-volume gate needed: a reset just removed the volume above, so it's always fresh. - // Passes the resolved reset `version`/`seedFlags`, unlike `db start`'s own call. + // Passes the resolved reset `version`, unlike `db start`'s own call. yield* runFreshDbSetup(spawner, { fs: input.fs, path: input.path, @@ -300,13 +319,37 @@ const recreateLocalDatabase15 = ( hostname: input.hostname, dbPort: input.dbPort, version: input.version, - seedFlags: input.seedFlags, setup: input.setup, }); yield* output.raw("Restarting containers...\n", "stderr"); - yield* restartServicesAndReloadKong(spawner, input.projectId); - }); + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? restartServicesAndReloadKong(spawner, input.projectId) + : Effect.gen(function* () { + const output = yield* Output; + const inspected = yield* inspectContainerState(spawner, input.dbContainerId).pipe( + Effect.result, + ); + const dbGone = Result.isFailure(inspected) + ? isContainerNotFoundMessage(inspected.failure.message) + : !inspected.success.running; + if (dbGone) { + return yield* output.warn( + `The local database is not running, so storage, auth, realtime and the pooler were left stopped.\n${STACK_RECOVERY_HINT}`, + ); + } + yield* restartServicesAndReloadKong(spawner, input.projectId).pipe( + Effect.catchTags({ + RestartServicesError: (error) => + output.warn(`${error.message}\n${STACK_RECOVERY_HINT}`), + KongReloadError: (error) => output.warn(`${error.message}\n${error.suggestion}`), + }), + ); + }), + ), + ); /** * Loads `config.toml` once, ahead of `initDatabase` (needs `api.auto_expose_new_tables`) and the @@ -325,6 +368,7 @@ const recreateLocalDatabase14 = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const { setup, fs, path, workdir } = input; @@ -378,9 +422,18 @@ const recreateLocalDatabase14 = ( // the satellite restarts inside `restartServicesAndReloadKong`. yield* output.raw("Restarting containers...\n", "stderr"); yield* restartContainer(spawner, input.dbContainerId); - yield* waitForHealthyServices(spawner, [input.dbContainerId], { - timeoutSeconds: input.dbHealthTimeoutSeconds, - }); + yield* waitForPostgresReady( + spawner, + input.dbContainerId, + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: dbPassword, + database: "postgres", + }, + { timeoutSeconds: input.dbHealthTimeoutSeconds }, + ); yield* restartServicesAndReloadKong(spawner, input.projectId); // Final connect as `postgres`/`postgres` to migrate and seed. @@ -389,7 +442,7 @@ const recreateLocalDatabase14 = ( const session = yield* connectAs("postgres", "postgres"); yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + seed: toml.seed, experimental: setup.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, @@ -416,11 +469,13 @@ export const recreateLocalDatabase = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => (input.setup.majorVersion <= 14 ? recreateLocalDatabase14(spawner, input) : recreateLocalDatabase15(spawner, input) ).pipe( + Effect.tap(() => awaitDockerHealthSettled(spawner, input.dbContainerId)), Effect.withSpan("DbBootstrap.recreateLocalDatabase", { attributes: { "db.major_version": input.setup.majorVersion }, }), diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts index bc1313b5eb..c05c77d8b5 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts @@ -63,7 +63,7 @@ describe("resetDisconnectClients", () => { ); it.effect( - "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins Go's `NewBackoffPolicy(ctx, 10*time.Second)` constant", + "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins the 10-second backoff limit", () => Effect.gen(function* () { // Never drains — pegs the retry schedule to its hard 10-retry ceiling. diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 8089335953..b41987edda 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -1,8 +1,9 @@ /** * Resets the local database in-process — shared by `db reset`'s handler and the `db schema * declarative`/`sync` local-reset paths, so neither needs to shell out to a separate process. - * `db reset`'s own handler is the only caller that ever passes a non-empty - * `version`/`seedFlags` override; the declarative callers always want the plain full reset. + * `db reset`'s own handler is the only caller that ever passes a non-empty `version`; the + * declarative callers always want the plain full reset. Seeding follows the resolved config: + * `--no-seed`, `--sql-paths` and `SUPABASE_DB_SEED_ENABLED` all arrive through it. * * Always prints its own two stderr lines via `output.raw`, regardless of `output.format`, but * never the JSON `output.success(...)` envelope — that belongs to a top-level `db reset` @@ -29,11 +30,10 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; -import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; +import { checkDbToml, loadProjectEnvValues, readDbToml } from "../db-config.toml-read.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; import { hasConfiguredBuckets, seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; -import { resolveResetSeedConfig } from "./db-setup.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; @@ -81,14 +81,9 @@ const toLogMessage = (version: string): string => export interface ResetLocalDatabaseInput { /** The resolved reset migration version (`""` for every pending migration, `db reset`'s default). */ readonly version: string; - /** `db reset`'s `--no-seed`/`--sql-paths` — see `resolveResetSeedConfig`. */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; } -const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { - version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, -}; +const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { version: "" }; const notRunning = () => new ResetLocalDbNotRunningError({ @@ -116,7 +111,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu const workdir = cliSettings.workdir; // Load the project env first so a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is // honored by the experimental gate below. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); @@ -130,11 +125,9 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu return yield* new ResetLocalDbNotRunningError({ message: "The local stack is not running." }); const catalog = yield* Effect.serviceOption(StackCatalogSetup); if (Option.isNone(catalog)) return yield* resetFailed("stack catalog setup is unavailable"); - const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message)); - const config = yield* loadStackConfig(workdir, { - context, - selection: readSelectionOrBundled, - }).pipe(Effect.mapError((cause) => resetFailed(cause.message, cause))); + const config = yield* loadStackConfig(workdir, { selection: readSelectionOrBundled }).pipe( + Effect.mapError((cause) => resetFailed(cause.message, cause)), + ); const toml = yield* readDbToml(fs, path, workdir); const databaseStatus = yield* opened.value.database.status.pipe( Effect.mapError((cause) => resetFailed(`failed to inspect stack: ${cause.message}`)), @@ -204,7 +197,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu overlay: projectCatalogOverlay(toml, workdir), migrations: { workdir, - toml: { ...toml, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path) }, + toml, experimental, version: input.version, }, @@ -230,7 +223,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // waits for `ready`. See docs/stack-commands.md#storage-and-bucket-seeding. const skipSeeding = ( reason: string, - nextStep = "Run supabase seed buckets --local once Storage is available.", + nextStep = "Run supabase seed buckets once Storage is available.", ) => output.raw( `${yellow("WARNING:")} skipped seeding storage buckets: ${reason} ${nextStep}\n`, @@ -240,6 +233,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // The database is already rebuilt, so any typed seeding failure only warns; defects and // interruption still propagate. yield* Effect.gen(function* () { + const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message)); if (!hasConfiguredBuckets(context.config)) return; const capability = storage === undefined ? undefined : yield* storage.status; const decision = classifyStorageCapability(capability); @@ -247,7 +241,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu yield* skipSeeding( describeStorageCapability(capability), decision === "disabled" - ? "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop followed by supabase stack start without -x storage, then supabase seed buckets --local." + ? "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop followed by supabase stack start without -x storage, then supabase seed buckets." : undefined, ); return; @@ -259,7 +253,10 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu interactive: false, yes, credentials, - resolvedConfig: { config: context.config, document: context.loaded?.document }, + resolvedConfig: { + config: context.config, + document: context.resolvedConfig.loaded.document, + }, projectEnvValues: projectEnv, workdir, }); @@ -282,13 +279,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu const debug = yield* DebugFlag; // Error if the local db container is down. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - workdir, - Option.getOrUndefined(cliSettings.projectId), - ); + const running = yield* isLocalDbRunning(spawner, fs, path, workdir); if (!running) { return yield* notRunning(); } @@ -305,7 +296,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu debug, ); const { - context: { projectId, hostname, config, loaded }, + context: { projectId, hostname, config, resolvedConfig }, values, bootstrapConfig, networkId, @@ -332,7 +323,6 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, version: input.version, - seedFlags: input.seedFlags, // `db reset` resolves `--experimental` earlier than this prelude does, via the nested-env // walk above; override the prelude's own `setup.experimental` with that value so the two // stay consistent. @@ -361,7 +351,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // `SUPABASE_YES` set in `supabase/.env` auto-confirms the bucket overwrite/prune // prompts. yes, - resolvedConfig: { config, document: loaded?.document }, + resolvedConfig: { config, document: resolvedConfig.loaded.document }, // The same nested-dotenv walk already resolved for `yes`/`experimental` above. projectEnvValues: projectEnv, }).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts index 376851bfbe..91d681f1b4 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -3,7 +3,8 @@ * `db reset` paths. Neither `db start` nor `supabase start` calls this: it exists purely to * bring the satellite containers (storage/auth/realtime/pooler) back in sync with a `db` * container that was just recreated or force-restarted out from under them, and to reload Kong's - * nginx so its cached upstream addresses (which may have changed) stop 502ing. + * nginx so its cached upstream addresses (which may have changed) stop 502ing. PG15's path also + * stops those satellites before it removes the `db` container ({@link stopSatelliteServices}). */ import { Data, Effect, Option, Result } from "effect"; @@ -54,18 +55,19 @@ export function restartContainer( } /** - * One satellite service's restart, tolerant of "not found" — a service excluded from the stack - * has no container to restart, and that's not an error. Never fails the surrounding `Effect.all` + * One satellite service's stop or restart, tolerant of "not found" — a service excluded from the + * stack has no container, and that's not an error. Never fails the surrounding `Effect.all` * itself: resolves `Option.some(message)` on a genuine failure so the caller can join every * service's outcome, and `Option.none()` on success or a tolerated not-found. */ -const restartSatelliteService = ( +const runSatelliteService = ( spawner: Spawner, + verb: "stop" | "restart", containerId: string, ): Effect.Effect> => Effect.scoped( Effect.gen(function* () { - const child = yield* spawnContainerCli(spawner, ["restart", containerId], { + const child = yield* spawnContainerCli(spawner, [verb, containerId], { stdin: "ignore", stdout: "ignore", stderr: "pipe", @@ -78,18 +80,18 @@ const restartSatelliteService = ( const trimmed = stderr.trim(); if (isContainerNotFoundMessage(trimmed)) return Option.none(); return Option.some( - `failed to restart ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, + `failed to ${verb} ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, ); }), ).pipe( Effect.catch((cause) => Effect.succeed( - Option.some(`failed to restart ${containerId}: ${describeContainerCliFailure(cause)}`), + Option.some(`failed to ${verb} ${containerId}: ${describeContainerCliFailure(cause)}`), ), ), ); -/** One or more satellite-service restarts failed. Messages are newline-joined. */ +/** One or more satellite-service stops or restarts failed. Messages are newline-joined. */ export class RestartServicesError extends Data.TaggedError("RestartServicesError")<{ readonly message: string; }> { @@ -99,13 +101,14 @@ export class RestartServicesError extends Data.TaggedError("RestartServicesError } /** - * Restarts storage/auth/realtime/pooler concurrently — not PostgREST, which reconnects and - * listens for schema changes on its own — without waiting for them to become healthy - * afterward, since some may be excluded from the stack. Every per-service failure (excluding a + * Stops or restarts storage/auth/realtime/pooler concurrently — not PostgREST, which reconnects and + * listens for schema changes on its own — and never waits for them to become healthy, since + * some may be excluded from the stack. Every per-service failure (excluding a * tolerated not-found) is joined into one newline-separated message. */ -function restartSatelliteServices( +function runSatelliteServices( spawner: Spawner, + verb: "stop" | "restart", projectId: string, ): Effect.Effect { const containerIds = [ @@ -116,14 +119,25 @@ function restartSatelliteServices( ]; return Effect.gen(function* () { const results = yield* Effect.all( - containerIds.map((containerId) => restartSatelliteService(spawner, containerId)), + containerIds.map((containerId) => runSatelliteService(spawner, verb, containerId)), { concurrency: "unbounded" }, ); const failures = results.filter(Option.isSome).map((result) => result.value); if (failures.length > 0) { return yield* new RestartServicesError({ message: failures.join("\n") }); } - }).pipe(Effect.withSpan("RestartServices.restartSatellites")); + }).pipe(Effect.withSpan(`RestartServices.${verb}Satellites`)); +} + +/** + * Stops storage/auth/realtime/pooler before `db reset` removes the db container: left running, + * `unless-stopped` restarts them and their own migrations race the one-shot init jobs (#6915). + */ +export function stopSatelliteServices( + spawner: Spawner, + projectId: string, +): Effect.Effect { + return runSatelliteServices(spawner, "stop", projectId); } /** Gateway-recovery hint, rendered as a `Suggestion:` line by `Output.fail`. */ @@ -225,7 +239,7 @@ export function restartServicesAndReloadKong( projectId: string, ): Effect.Effect { return Effect.gen(function* () { - yield* restartSatelliteServices(spawner, projectId); + yield* runSatelliteServices(spawner, "restart", projectId); yield* reloadKong(spawner, projectId); }).pipe(Effect.withSpan("RestartServices.restartAndReloadKong")); } diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index 9ee5e8af4e..ab1b4a7b11 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -1,9 +1,11 @@ import { Effect, FileSystem, Path } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; +import { isContainerNotFoundMessage } from "../container-cli.ts"; import type { ContainerIdName } from "../docker-lifecycle.ts"; -import { dockerRemoveAll } from "../docker-remove-all.ts"; +import { dockerRemoveAll, pruneProjectNetworks } from "../docker-remove-all.ts"; import { cleanupStartSecrets } from "../start-secrets-cleanup.ts"; +import { isVolumeNotFoundMessage, removeContainer, removeVolume } from "./container-lifecycle.ts"; import { HealthCheckTimeoutError } from "./health-check.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -34,7 +36,7 @@ export const rollbackStart = ( Effect.gen(function* () { // `dockerRemoveAll` doesn't print this; each caller owns its own status writer. yield* Effect.sync(() => { - globalThis.process.stderr.write("Stopping containers...\n"); + process.stderr.write("Stopping containers...\n"); }); let removedContainers: ReadonlyArray = []; yield* dockerRemoveAll( @@ -48,7 +50,7 @@ export const rollbackStart = ( ).pipe( Effect.catch((error) => Effect.sync(() => { - globalThis.process.stderr.write(`${error.message}\n`); + process.stderr.write(`${error.message}\n`); }), ), ); @@ -59,3 +61,60 @@ export const rollbackStart = ( attributes: { "rollback.delete_volumes": deleteVolumes }, }), ); + +const writeStderrLine = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); + +/** Resolves whether the resource is gone; a missing resource counts, any other failure is logged. */ +const removeLoggingFailure = ( + remove: Effect.Effect, + isMissing: (message: string) => boolean, +) => + remove.pipe( + Effect.catchIf( + (error) => isMissing(error.message), + () => Effect.void, + ), + Effect.as(true), + Effect.catch((error) => writeStderrLine(error.message).pipe(Effect.as(false))), + ); + +/** + * `db start`'s rollback: removes only the db container, its volume when `deleteVolume`, and the + * project's unused networks, so the project's other services and volumes survive a failed run. + * A rollback failure is only logged to stderr, never propagated. + */ +export const rollbackDatabaseStart = ( + spawner: Spawner, + filterValue: string, + dbContainerId: string, + deleteVolume: boolean, + debug: boolean, +): Effect.Effect => + Effect.gen(function* () { + yield* writeStderrLine("Stopping containers..."); + + const containerRemoved = yield* removeLoggingFailure( + removeContainer(spawner, dbContainerId), + isContainerNotFoundMessage, + ); + yield* Effect.annotateCurrentSpan("container.removed", containerRemoved); + + if (deleteVolume) { + const volumeRemoved = yield* removeLoggingFailure( + removeVolume(spawner, dbContainerId), + isVolumeNotFoundMessage, + ); + yield* Effect.annotateCurrentSpan("volume.removed", volumeRemoved); + } + + yield* pruneProjectNetworks(spawner, filterValue, debug).pipe( + Effect.catch((error) => writeStderrLine(error.message)), + ); + }).pipe( + Effect.withSpan("DbBootstrap.rollbackDatabaseStart", { + attributes: { "rollback.delete_volumes": deleteVolume }, + }), + ); diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts index 7aecc2c49f..ff072f9066 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts @@ -1,10 +1,10 @@ import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; -import { Data, Deferred, Effect, Sink, Stream } from "effect"; +import { Data, Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { HealthCheckTimeoutError } from "./health-check.ts"; -import { isUnhealthyStartError, rollbackStart } from "./rollback.ts"; +import { isUnhealthyStartError, rollbackDatabaseStart, rollbackStart } from "./rollback.ts"; function captureStderr() { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); @@ -14,16 +14,19 @@ afterEach(() => { vi.restoreAllMocks(); }); +interface SpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; +} + /** * Every spawned command answers with the same fixed `exitCode`/`stdout`/`stderr`, enough to * drive `dockerRemoveAll` through either its success path or its first failure branch. + * `respond` answers per command instead. */ function mockSpawner( - opts: { - readonly exitCode?: number; - readonly stdout?: string; - readonly stderr?: string; - } = {}, + opts: SpawnResult & { readonly respond?: (args: ReadonlyArray) => SpawnResult } = {}, ) { const encoder = new TextEncoder(); const spawned: Array> = []; @@ -32,14 +35,19 @@ function mockSpawner( Effect.gen(function* () { const args = command._tag === "StandardCommand" ? command.args : []; spawned.push(args); + const result = opts.respond?.(args) ?? opts; const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), - stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), all: Stream.empty, exitCode: Deferred.await(exitDeferred), isRunning: Effect.succeed(false), @@ -133,6 +141,130 @@ describe("rollbackStart", () => { }); }); +describe("rollbackDatabaseStart", () => { + const FILTER = "com.supabase.cli.project=my-app"; + const DB = "supabase_db_my-app"; + const removeDb = ["container", "rm", "-f", DB]; + const removeDbVolume = ["volume", "rm", "-f", DB]; + const pruneNetworks = ["network", "prune", "--force", "--filter", `label=${FILTER}`]; + + it.live("removes only the db container, then prunes the project's networks", () => { + const mock = mockSpawner(); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, false, false); + expect(mock.spawned).toEqual([removeDb, pruneNetworks]); + expect(stderr.mock.calls).toEqual([["Stopping containers...\n"]]); + }); + }); + + it.live("also removes the db volume, after the container, when the volume is fresh", () => { + const mock = mockSpawner(); + captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, true, false); + expect(mock.spawned).toEqual([removeDb, removeDbVolume, pruneNetworks]); + }); + }); + + it.live("reports the pruned networks to stderr in debug mode", () => { + const mock = mockSpawner({ + respond: (args) => + args[0] === "network" ? { stdout: "Deleted Networks:\nsupabase_network_my-app\n" } : {}, + }); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, false, true); + expect(stderr.mock.calls).toEqual([ + ["Stopping containers...\n"], + ["Pruned network: [supabase_network_my-app]\n"], + ]); + }); + }); + + const removalOutcomes = [ + { + scenario: "a missing container", + failing: "container rm", + stderr: `Error response from daemon: No such container: ${DB}`, + logged: undefined, + }, + { + scenario: "a missing container reported by Podman", + failing: "container rm", + stderr: `Error: no container with name or ID "${DB}" found: no such container`, + logged: undefined, + }, + { + scenario: "a missing volume", + failing: "volume rm", + stderr: `Error response from daemon: No such volume: ${DB}`, + logged: undefined, + }, + { + scenario: "a container that cannot be removed", + failing: "container rm", + stderr: "permission denied", + logged: "failed to remove container: permission denied", + }, + { + scenario: "a volume that cannot be removed", + failing: "volume rm", + stderr: `Error response from daemon: remove ${DB}: volume is in use`, + logged: `failed to remove volume: Error response from daemon: remove ${DB}: volume is in use`, + }, + { + scenario: "networks that cannot be pruned", + failing: "network prune", + stderr: "permission denied", + logged: "failed to prune networks: permission denied", + }, + ] as const; + for (const { scenario, failing, stderr: failureStderr, logged } of removalOutcomes) { + it.live( + `${logged === undefined ? "silently continues" : "logs the failure and continues"} past ${scenario}`, + () => { + const mock = mockSpawner({ + respond: (args) => + args.slice(0, 2).join(" ") === failing ? { exitCode: 1, stderr: failureStderr } : {}, + }); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, true, false); + expect(mock.spawned).toEqual([removeDb, removeDbVolume, pruneNetworks]); + expect(stderr.mock.calls).toEqual([ + ["Stopping containers...\n"], + ...(logged === undefined ? [] : [[`${logged}\n`]]), + ]); + }); + }, + ); + } + + it.live("never fails when no container runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(spawner, FILTER, DB, true, false); + expect(stderr.mock.calls.map(([line]) => line)).toEqual([ + "Stopping containers...\n", + expect.stringContaining("failed to remove container"), + expect.stringContaining("failed to remove volume"), + expect.stringContaining("failed to prune networks"), + ]); + }); + }); +}); + class OtherTaggedError extends Data.TaggedError("OtherTaggedError")<{ readonly message: string; }> {} diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts index 135cd87894..3b4efb4bea 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts @@ -19,7 +19,7 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Option, Path } from "effect"; import { afterEach, describe, expect, test } from "vitest"; -import { makeTempHome, runSupabase } from "../../../tests/helpers/cli.ts"; +import { createTempHome, runSupabase, type TempHome } from "../../../tests/helpers/cli.ts"; const CLEANUP_TIMEOUT_MS = 120_000; @@ -46,7 +46,7 @@ const STACK_PORT_ASSIGNMENT = */ const SHADOW_PORT_CANDIDATE_COUNT = 8; -const DIFF_ARGS = ["db", "diff", "--local", "--use-pg-delta"] as const; +const DIFF_ARGS = ["db", "diff", "--use-pg-delta"] as const; /** `shadow-cache.ts`'s published artifact name — `shadow-baseline-<16 hex key>.tar`. */ const BASELINE_TAR_PATTERN = /^shadow-baseline-[0-9a-f]{16}\.tar$/u; @@ -97,7 +97,7 @@ const mtimeMs = Effect.fnUntraced(function* (filePath: string) { describe("shadow baseline cache (e2e, local Docker stack)", () => { let projectDir: string | undefined; - let home: ReturnType | undefined; + let home: TempHome | undefined; afterEach(() => Effect.runPromise( @@ -116,7 +116,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { yield* fs.remove(projectDir, { recursive: true, force: true }).pipe(Effect.ignore); } // Disposes the temp `SUPABASE_HOME`, and with it the ~90MB baseline tar this suite published. - home?.[Symbol.dispose](); + if (home !== undefined) yield* home.dispose; projectDir = undefined; home = undefined; }).pipe(Effect.provide(BunServices.layer)), @@ -140,7 +140,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { projectDir = yield* fs.makeTempDirectory({ prefix: "sb-shadow-cache-e2e-" }); // One temp `SUPABASE_HOME` per test run so both `db diff` processes share the same cache // directory; `runSupabase` otherwise mints a fresh home per invocation. - home = makeTempHome(); + home = yield* createTempHome; const homeDir = home.dir; const cacheDir = path.join(home.dir, "cache", "shadow-baseline"); @@ -192,7 +192,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { expect(shadowPortCandidates).toHaveLength(SHADOW_PORT_CANDIDATE_COUNT); // Same drift setup as `db/diff/diff.declarative.e2e.test.ts`: create a fresh function - // directly in the local database so `db diff --local` has real, deterministic SQL to + // directly in the local database so `db diff` has real, deterministic SQL to // produce — the byte-identity of that output across cold/warm runs is the actual // user-visible contract here. Schema files can't supply this drift since the next engine // ignores `schema_paths` when building its migrations baseline. @@ -205,7 +205,6 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { returns void language sql as $$ select 1; $$;`, - "--local", ], { cwd: projectDir, home: homeDir }, ), diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 67e575fa52..2a6a5a2d9c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -42,9 +42,9 @@ import { PGDATA_BASELINE_MARKER_ENTRY, PGDATA_BASELINE_MARKER_NAME, PGDATA_PARENT_PATH, - PGDATA_PATH, pgDataBaselineMarkerContent, } from "./pgdata-snapshot.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; import { SHADOW_BASELINE_KEEP, SHADOW_CACHE_ENV, @@ -75,10 +75,9 @@ const withShadowCacheHome = ( value: string | undefined, body: Effect.Effect, ): Effect.Effect => - withEnvVar( - "SUPABASE_HOME", - nodePath.join(tempRoot.current, "_supabase_home"), - withShadowCacheEnv(value, body), + withShadowCacheEnv( + value, + withEnvVar("SUPABASE_HOME", nodePath.join(tempRoot.current, "_supabase_home"), body), ); function fakeCluster(opts: { readonly failConnect?: boolean } = {}) { @@ -155,7 +154,7 @@ const shadowCacheDir = (path: Path.Path) => shadowBaselineCacheDir(path); /** The snapshot tars in the global cache dir, whatever keys they belong to. */ const soleTarName = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, path: Path.Path) { const entries = yield* fs - .readDirectory(shadowCacheDir(path)) + .readDirectory(yield* shadowCacheDir(path)) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); return entries.filter((entry) => entry.endsWith(".tar")); }); @@ -296,7 +295,7 @@ describe("acquireShadowDatabase", () => { yield* coldRun(docker, input); // A concurrent writer SIGKILLed mid-export: its partial is older than 5 minutes. const abandoned = path.join( - shadowCacheDir(path), + yield* shadowCacheDir(path), "shadow-baseline-0011223344556677.tar.4242.partial", ); yield* fs.writeFileString(abandoned, "stale"); @@ -377,7 +376,7 @@ describe("acquireShadowDatabase", () => { // A regular file occupies the cache root's path, so its mkdir can never succeed — the // same terminal shape as an unwritable or root-squashed `SUPABASE_HOME`. The acquire // must degrade to the plain uncached shadow rather than pay a doomed export cycle. - const cacheDir = shadowCacheDir(path); + const cacheDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(path.dirname(cacheDir), { recursive: true }); yield* fs.writeFileString(cacheDir, "not a directory"); @@ -405,7 +404,7 @@ describe("acquireShadowDatabase", () => { // Recursive mkdir on an existing directory creates nothing and succeeds regardless of // permission, so the acquire's probe must check write access explicitly — otherwise a // read-only root selects the doomed cold cycle on every default-ON invocation. - const cacheDir = shadowCacheDir(path); + const cacheDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(cacheDir, { recursive: true }); yield* fs.chmod(cacheDir, 0o500); // chmod cannot revoke write access from a privileged user (root ignores permission @@ -487,7 +486,7 @@ describe("acquireShadowDatabase", () => { expect(docker.stepCalls("cp-stamp")[0]).toEqual([ "cp", "-", - `${handle.containerId}:${PGDATA_PATH}`, + `${handle.containerId}:${POSTGRES_DATA_DIR}`, ]); // The stamp really carries the marker file, delivered as a tar so `docker cp` unpacks it // relative to PGDATA rather than rewriting the directory's ownership — and its content is @@ -497,7 +496,7 @@ describe("acquireShadowDatabase", () => { expect(stamp).toContain(pgDataBaselineMarkerContent(handle.snapshotKey ?? "")); expect(docker.stepCalls("cp-out")[0]).toEqual([ "cp", - `${handle.containerId}:${PGDATA_PATH}`, + `${handle.containerId}:${POSTGRES_DATA_DIR}`, "-", ]); expect(docker.containers.get(handle.containerId)?.running).toBe(true); @@ -505,13 +504,15 @@ describe("acquireShadowDatabase", () => { const tars = yield* soleTarName(fs, path); expect(tars).toHaveLength(1); expect(tars[0]).toMatch(/^shadow-baseline-[0-9a-f]{16}\.tar$/u); - const published = yield* fs.readFileString(path.join(shadowCacheDir(path), tars[0] ?? "")); + const published = yield* fs.readFileString( + path.join(yield* shadowCacheDir(path), tars[0] ?? ""), + ); expect(published).toBe(expectedTarFor(tars[0] ?? "")); expect(keyOf(tars[0] ?? "")).toBe(handle.snapshotKey); // The stamp made it all the way into the artifact — this is the entry the next run's // pre-restore scan requires, so a cold export that skipped it would never warm anything. expect(published).toContain(PGDATA_BASELINE_MARKER_ENTRY); - const leftovers = yield* fs.readDirectory(shadowCacheDir(path)); + const leftovers = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(leftovers.filter((entry) => entry.includes("partial"))).toEqual([]); yield* removeShadowDatabase(docker.spawner, handle.containerId); @@ -570,7 +571,7 @@ describe("acquireShadowDatabase", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const input = shadowInput(fs, path); - const tempDir = shadowCacheDir(path); + const tempDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); // An adversarially (or crash-) pre-created temp file at this process's own temp path, // world-readable. The pre-remove + `wx` exclusive-create guarantees a fresh 0600 inode. @@ -601,7 +602,7 @@ describe("acquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const tempDir = shadowCacheDir(path); + const tempDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); // A SIGKILLed export leftover (older than 5 minutes) and a live writer's fresh temp file. const abandoned = path.join(tempDir, "shadow-baseline-0123456789abcdef.tar.99999.partial"); @@ -644,14 +645,18 @@ describe("acquireShadowDatabase", () => { expect(rekeyed.baselinePresent).toBe(false); // An unrelated file in the cache directory is untouched by retention. - const stray = path.join(shadowCacheDir(path), "catalog-abc.json"); + const stray = path.join(yield* shadowCacheDir(path), "catalog-abc.json"); yield* fs.writeFileString(stray, "{}"); // mtime is the LRU ordinal, and the rapid-fire publishes below can land within the // filesystem's timestamp granularity, making an mtime tie's "oldest" ambiguous. Age the // first tar explicitly since this test asserts keep-cap behavior, not tie-breaking. const anHourAgo = DateTime.toDateUtc(DateTime.subtract(yield* DateTime.now, { hours: 1 })); - yield* fs.utimes(path.join(shadowCacheDir(path), first[0] ?? ""), anHourAgo, anHourAgo); + yield* fs.utimes( + path.join(yield* shadowCacheDir(path), first[0] ?? ""), + anHourAgo, + anHourAgo, + ); // Fill past keep-cap. The current key is retained, so siblings evict first. for (let i = 0; i < SHADOW_BASELINE_KEEP; i++) { @@ -836,7 +841,7 @@ describe("acquireShadowDatabase", () => { // The caller is about to reconnect, so the container is running again regardless. expect(docker.containers.get(handle.containerId)?.running).toBe(true); // Neither a published tar nor a half-written temp file survives. - const entries = yield* fs.readDirectory(shadowCacheDir(path)); + const entries = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(entries).toEqual([]); }), ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); @@ -874,7 +879,7 @@ describe("acquireShadowDatabase", () => { // check that treated "tar exists" as "sibling just published" would leave this // garbage in place forever. const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); yield* fs.writeFileString(tarPath, "not-a-real-snapshot"); yield* fallback.snapshotBaseline; expect(yield* soleTarName(fs, path)).toHaveLength(1); @@ -896,7 +901,7 @@ describe("acquireShadowDatabase", () => { const input = shadowInput(fs, path); yield* coldRun(docker, input); const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); // The published artifact is replaced by a tar that is well-formed but carries no PGDATA: // `docker cp -` would extract nothing, the entrypoint would `initdb` a fresh cluster, @@ -935,7 +940,7 @@ describe("acquireShadowDatabase", () => { const input = shadowInput(fs, path); yield* coldRun(docker, input); const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); // A perfectly restorable PGDATA that never ran the platform baseline — the failure // `data/PG_VERSION` alone cannot see: `docker cp -` extracts a genuine cluster, the @@ -981,8 +986,8 @@ describe("acquireShadowDatabase", () => { const keyA = coldA.snapshotKey ?? ""; const keyB = coldB.snapshotKey ?? ""; expect(keyA).not.toBe(keyB); - const tarPathB = path.join(shadowCacheDir(path), `shadow-baseline-${keyB}.tar`); - const tarPathA = path.join(shadowCacheDir(path), `shadow-baseline-${keyA}.tar`); + const tarPathB = path.join(yield* shadowCacheDir(path), `shadow-baseline-${keyB}.tar`); + const tarPathA = path.join(yield* shadowCacheDir(path), `shadow-baseline-${keyA}.tar`); // A's snapshot copied over B's cache file — the shape a copied `~/.supabase/cache` // directory, a restored backup, or a hand-renamed tar produces. Every entry the @@ -1101,10 +1106,10 @@ describe("acquireShadowDatabase", () => { expect(docker.stepCalls("cp-out")).toHaveLength(1); const tars = yield* soleTarName(fs, path); expect(tars).toHaveLength(1); - expect(yield* fs.readFileString(path.join(shadowCacheDir(path), tars[0] ?? ""))).toBe( - expectedTarFor(tars[0] ?? ""), - ); - const leftovers = yield* fs.readDirectory(shadowCacheDir(path)); + expect( + yield* fs.readFileString(path.join(yield* shadowCacheDir(path), tars[0] ?? "")), + ).toBe(expectedTarFor(tars[0] ?? "")); + const leftovers = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(leftovers.filter((entry) => entry.includes("partial"))).toEqual([]); expect(docker.containers.get(first.containerId)?.running).toBe(true); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 8ecf4f3542..42f45b851f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -21,7 +21,7 @@ import { } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; -import { viperEnvBoolWithProjectFallback } from "../viper-env.ts"; +import { supabaseEnvBoolWithProjectFallback } from "../supabase-env.ts"; import { Output } from "../../shared/output/output.service.ts"; import { containerCliExitCode, describeContainerCliFailure } from "../container-cli.ts"; import { DbConnection } from "../db-connection.service.ts"; @@ -40,7 +40,7 @@ import { START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; import { START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; import { CREATE_VAULT_KV, READ_VAULT_KV, UPDATE_VAULT_KV, type VaultSecret } from "../vault.ts"; -import { waitForShadowReady } from "./health-check.ts"; +import { waitForPostgresReady } from "./health-check.ts"; import { exportPgDataTar, pgDataRestoreArchive, @@ -61,7 +61,7 @@ import { type Spawner = ChildProcessSpawner["Service"]; -/** `SUPABASE_SHADOW_CACHE` — opt-out gate (viper bool when set; unset is ON). */ +/** `SUPABASE_SHADOW_CACHE` — opt-out gate (parsed as a boolean when set; unset is ON). */ export const SHADOW_CACHE_ENV = "SUPABASE_SHADOW_CACHE"; /** @@ -434,7 +434,7 @@ const SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; */ const sweepAbandonedShadowBaselinePartials = (input: ShadowSetupInput): Effect.Effect => Effect.gen(function* () { - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const entries = yield* input.fs .readDirectory(cacheDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -464,7 +464,7 @@ const sweepShadowBaselineRetention = ( retainFileName?: string, ): Effect.Effect => Effect.gen(function* () { - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const names = yield* input.fs .readDirectory(cacheDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -532,7 +532,7 @@ const awaitShadowReady = ( containerId: string, what: string, ): Effect.Effect => - waitForShadowReady(spawner, containerId, shadowConnConfig(input), { + waitForPostgresReady(spawner, containerId, shadowConnConfig(input), { timeoutSeconds: input.healthTimeoutSeconds, image: input.image, }).pipe( @@ -569,7 +569,7 @@ const writeShadowBaselineTar = ( const published = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); if (published) return; } - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); yield* input.fs .makeDirectory(cacheDir, { recursive: true, mode: 0o700 }) .pipe( @@ -685,7 +685,7 @@ export const peekShadowBaseline = ( Effect.gen(function* () { if ( opts.bypassCache === true || - !(yield* viperEnvBoolWithProjectFallback( + !(yield* supabaseEnvBoolWithProjectFallback( SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, )) @@ -696,7 +696,7 @@ export const peekShadowBaseline = ( if (Option.isNone(keyInputs)) return { state: "uncachable" } as const; const key = shadowCacheKey(keyInputs.value); const tarPath = input.path.join( - shadowBaselineCacheDir(input.path), + yield* shadowBaselineCacheDir(input.path), shadowBaselineTarFileName(key), ); const cached = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); @@ -868,7 +868,7 @@ export const acquireShadowDatabase = ( Effect.gen(function* () { if ( opts.bypassCache === true || - !(yield* viperEnvBoolWithProjectFallback( + !(yield* supabaseEnvBoolWithProjectFallback( SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { @@ -896,7 +896,7 @@ export const acquireShadowDatabase = ( // can't be written. The mkdir alone isn't a sufficient probe — it succeeds on an // already-existing directory regardless of permission — so `access(W_OK)` catches a // pre-existing read-only root (EACCES, EROFS, a root-squashing NFS server). - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const cacheRoot = yield* Effect.result( input.fs .makeDirectory(cacheDir, { recursive: true, mode: 0o700 }) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index e833233bf9..e3b6c57e24 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -749,8 +749,8 @@ const migrateShadowDatabaseWith = ( ); /** - * Migrates a shadow for migra and the legacy pg-delta engine. Those Go-backed - * workflows historically include `pg_net` in the platform baseline regardless of + * Migrates a shadow for migra and the legacy pg-delta engine. Those + * workflows include `pg_net` in the platform baseline regardless of * project config, so preserve that baseline while sharing the native TS setup path. */ export const migrateShadowDatabase = ( diff --git a/apps/cli/src/command-internal/db-bootstrap/slim-runtime.ts b/apps/cli/src/command-internal/db-bootstrap/slim-runtime.ts index bc65badb39..c9e65587eb 100644 --- a/apps/cli/src/command-internal/db-bootstrap/slim-runtime.ts +++ b/apps/cli/src/command-internal/db-bootstrap/slim-runtime.ts @@ -1,7 +1,7 @@ /** * Slim-image runtime contracts that still differ from docker.io. Spec builders * switch on {@link usesSlimImageRuntime} so flag-off stays byte-identical even - * if a caller passes a ghcr-shaped override. + * if a caller passes a slim-shaped override. * * Slim auth, storage, Vector, and the Elixir images (realtime, analytics, * pooler) ship BusyBox wget — not GNU wget and not curl. BusyBox documents diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 87268aef9c..31af5f464f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -1,8 +1,9 @@ /** * The single bring-up sequence for the local Postgres container, shared by `supabase start` and - * `db start`: volume probe, image resolve and network ensure, create and start, health wait - * (swallowed only for `fromBackup`), fresh-volume setup (skipped for `fromBackup`), then - * `initCurrentBranch`. `--ignore-health-check` and rollback are the caller's concern. + * `db start`: volume probe, image resolve and network ensure, create (probing a reused volume's + * data directory before start) and start, health wait (swallowed only for `fromBackup`), setup + * when the volume is new or its data directory was never initialised (skipped for `fromBackup`), + * then `initCurrentBranch`. `--ignore-health-check` and rollback are the caller's concern. * * `resolvePostgresImage` and `setup.jwks` are caller-supplied `Effect`s because `db start` * resolves them lazily while `supabase start` threads values it already resolved. @@ -27,6 +28,7 @@ import type { DockerRun } from "../docker-run.service.ts"; import { ensureNetwork, createContainer, + postgresDataInitialized, volumeExists, COMPOSE_PROJECT_LABEL, type ContainerCreateError, @@ -44,11 +46,13 @@ import { type StartSetupLocalDatabaseError, } from "./db-setup.ts"; import type { ImagePrepullError } from "./image-prepull.ts"; -import { waitForHealthyServices, type HealthCheckTimeoutError } from "./health-check.ts"; +import { waitForPostgresReady, type HealthCheckTimeoutError } from "./health-check.ts"; +import { startInternalDbPassword } from "./internal-db-connection.ts"; import { buildPostgresStartContainerSpec, type PostgresStartServiceInput, } from "./postgres.service.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; const START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; // Printed when an existing volume is reused; unrelated to `--from-backup`. @@ -71,6 +75,13 @@ class StartBackupVolumeExistsError extends Data.TaggedError("StartBackupVolumeEx } } +/** The refusal for `--from-backup` against an existing data volume. */ +export const backupVolumeExistsError = () => + new StartBackupVolumeExistsError({ + message: "backup volume already exists", + suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, + }); + /** Every failure {@link startDatabase} itself can produce, independent of the caller's own `E`. */ export type StartDatabaseError = | NetworkCreateError @@ -108,9 +119,10 @@ export interface StartDatabaseInput { readonly webhooksEnabled: boolean; readonly setup: FreshDbSetupInput; /** - * Reports whether the volume was freshly created, for the caller's own rollback logic. Fired - * once after the pre-create refuse guards pass, so rollback never treats a leftover sibling - * volume as this run's fresh data. + * Reports whether the volume holds no initialised data, for the caller's own rollback logic. + * Fires `false` for an existing volume once the pre-create refuse guards pass, so rollback never + * treats a leftover sibling volume as this run's fresh data, then `true` if the container's data + * directory turns out to be uninitialised. */ readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; } @@ -132,6 +144,7 @@ export const startDatabase = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const output = yield* Output; @@ -140,32 +153,29 @@ export const startDatabase = ( // `docker network create` are idempotent, so creating either first would make "did this // volume already exist" unobservable and could leave a network behind for a request the // guard below is about to reject. - const isFreshVolume = !(yield* volumeExists(spawner, input.dbContainerId)); + const volumeExisted = yield* volumeExists(spawner, input.dbContainerId); const fromBackup = input.postgresSpec.fromBackup; yield* Effect.annotateCurrentSpan({ - "db.fresh_volume": isFreshVolume, + "db.volume_existed": volumeExisted, "db.from_backup": fromBackup !== undefined, }); - if (!isFreshVolume && fromBackup !== undefined) { + if (volumeExisted && fromBackup !== undefined) { // Refused before any container or network is created, and before freshness is published, // so rollback cannot prune it. - return yield* new StartBackupVolumeExistsError({ - message: "backup volume already exists", - suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, - }); + return yield* backupVolumeExistsError(); } // Print this before image resolve so a flag-off cold/failed pull still // follows the established progress order. yield* output.raw( - isFreshVolume ? START_STARTING_DATABASE_MESSAGE : START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, + volumeExisted ? START_STARTING_DATABASE_FROM_BACKUP_MESSAGE : START_STARTING_DATABASE_MESSAGE, "stderr", ); const resolvedPostgresImage = yield* input.resolvePostgresImage; - input.onFreshVolumeResolved(isFreshVolume); + input.onFreshVolumeResolved(!volumeExisted); // Runs once per `start` run rather than once per container — see `ensureNetwork`'s own doc // comment — but kept after the volume probe/guard above, never before it. @@ -178,19 +188,43 @@ export const startDatabase = ( ...input.postgresSpec, image: resolvedPostgresImage, }); - yield* createContainer(spawner, postgresSpec, input.containerOpts); + // A reused volume with no `PG_VERSION` was never initialised, so it still needs the + // fresh-volume setup. Published before `docker start` so a failed start rolls the volume back. + let dataInitialized = true; + yield* createContainer( + spawner, + postgresSpec, + input.containerOpts, + volumeExisted + ? (containerId) => + postgresDataInitialized(spawner, containerId).pipe( + Effect.map((initialized) => { + dataInitialized = initialized; + if (!initialized) input.onFreshVolumeResolved(true); + }), + ) + : undefined, + ); + const isFreshVolume = !volumeExisted || !dataInitialized; + yield* Effect.annotateCurrentSpan({ "db.fresh_volume": isFreshVolume }); - const postgresHealthResult = yield* waitForHealthyServices( + // A host connect, not Docker's HEALTHCHECK: that needs `docker exec`, which nested or + // sandboxed Docker cannot do. + const postgresHealthResult = yield* waitForPostgresReady( spawner, - [postgresSpec.containerName], + postgresSpec.containerName, { - timeoutSeconds: input.dbHealthTimeoutSeconds, - images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: startInternalDbPassword(input.setup.dbUrl), + database: "postgres", }, + { timeoutSeconds: input.dbHealthTimeoutSeconds, image: resolvedPostgresImage }, ).pipe(Effect.result); if (Result.isFailure(postgresHealthResult)) { // Discarded only when `fromBackup` is set — the log dump to stderr already happened - // inside `waitForHealthyServices` regardless. Any other failure propagates bare; see this + // inside `waitForPostgresReady` regardless. Any other failure propagates bare; see this // module's header for why `--ignore-health-check` is entirely the caller's concern. if (fromBackup === undefined) { return yield* postgresHealthResult.failure; @@ -208,10 +242,7 @@ export const startDatabase = ( networkId: input.networkId, hostname: input.hostname, dbPort: input.dbPort, - // Every pending migration, no seed override — `db start` has neither `--no-seed` nor - // `--sql-paths`. version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, setup: input.setup, }); } else if (fromBackup === undefined) { diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index 2942c5e4b2..37ac986ec0 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -8,7 +8,7 @@ * {@link StartLocalDatabaseResult} discriminator instead of printing the terminal line itself. */ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { Output } from "../../shared/output/output.service.ts"; @@ -18,42 +18,24 @@ import { CommandSettings } from "../../config/command-settings.service.ts"; import { checkDbToml } from "../db-config.toml-read.ts"; import { DbConfigLoadError } from "../db-config.errors.ts"; import { - envOverride, - envOverrideApiMaxRows, - envOverrideAuthPasswordRequirements, - envOverrideBool, - envOverrideDefaultPoolSize, - envOverrideEdgeRuntimePolicy, - envOverrideMaxClientConn, - envOverridePoolMode, - envOverridePort, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - envOverrideUint, - resolveAuthEmail, - resolveAuthEmailSmtp, resolveAuthExternalProviders, - resolveAuthHooks, - resolveAuthMfa, - resolveAuthSms, - resolveDbSettingsEnvOverrides, - resolveGotrueOAuthServer, resolveGotruePasskeyWebauthn, - resolveGotrueRateLimit, - resolveGotrueSessions, - resolveGotrueWeb3, resolveLocalConfigValues, - resolveThirdPartyProviders, } from "../local-config-values.ts"; -import { parseGoDuration, resolveHealthTimeoutSeconds } from "../go-duration.ts"; +import { parseDuration, resolveHealthTimeoutSeconds } from "../duration.ts"; import { ramInBytes } from "../size-units.ts"; -import { goUrlParse } from "../storage-url.ts"; +import { parseUrl } from "../storage-url.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; -import { cliProjectFilterValue } from "../docker-ids.ts"; +import { cliProjectFilterValue, localDbContainerId } from "../docker-ids.ts"; +import { isBitbucketPipeline } from "../bitbucket-pipeline.ts"; +import { holdProjectLifecycleLock } from "../project-lifecycle-lock.ts"; +import { dbNotRunningError } from "../status-errors.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; -import { isLocalDbRunning } from "./local-db-running.ts"; -import { rollbackStart } from "./rollback.ts"; -import { startDatabase } from "./start-database.ts"; +import { removeContainer } from "./container-lifecycle.ts"; +import { awaitDockerHealthSettled } from "./health-check.ts"; +import { localDbState } from "./local-db-running.ts"; +import { rollbackDatabaseStart } from "./rollback.ts"; +import { backupVolumeExistsError, startDatabase } from "./start-database.ts"; function asRecord(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -61,23 +43,17 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +const dbConfigOverrideError = (dottedFieldPath: string) => (cause: unknown) => + new DbConfigLoadError({ + message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + /** - * Wraps a synchronous resolver/parser that throws on a malformed config value into a typed - * `DbConfigLoadError` failure — mirrors `commands/start/start.handler.ts`'s identical - * `wrapConfigOverride`: config loading hard-fails on a bad decode before any Docker work runs. + * Maps a synchronous parser's malformed-value failure into a typed `DbConfigLoadError` — config + * loading hard-fails on a bad decode before any Docker work runs. */ -function wrapDbConfigOverride( - dottedFieldPath: string, - thunk: () => T, -): Effect.Effect { - return Effect.try({ - try: thunk, - catch: (cause) => - new DbConfigLoadError({ - message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); -} +const wrapDbConfigParse = (dottedFieldPath: string, thunk: () => T) => + Effect.try({ try: thunk, catch: dbConfigOverrideError(dottedFieldPath) }); type StartLocalDatabaseStatus = "already-running" | "started"; @@ -85,15 +61,8 @@ interface StartLocalDatabaseResult { readonly status: StartLocalDatabaseStatus; } -/** - * Starts the local Postgres database, or no-ops when it is already running. See this module's - * own header for the full design rationale. - * - * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the caller's cwd - * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with - * no argument. - */ -export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(function* ( +/** Takes the project lifecycle lock into its scope; {@link startLocalDatabase} closes that scope after rollback. */ +const startLocalDatabaseInScope = Effect.fn("DbBootstrap.startLocalDatabase")(function* ( fromBackupFlag?: string, ) { const output = yield* Output; @@ -110,7 +79,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // `isLocalDbRunning`'s best-effort read, which swallows config errors. const dbTomlValues = yield* checkDbToml(fs, path, cliSettings.workdir); - // Threaded into `rollbackStart`'s own teardown (gates its `Pruned …:` stderr reports) and into + // Threaded into `rollbackDatabaseStart`'s own teardown (gates its `Pruned …:` stderr reports) and into // `buildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume migrate job tees // its own stderr. Resolved with the `SUPABASE_DEBUG` shell/project-`.env` fallback, not the // bare flag. @@ -124,430 +93,69 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu (message) => new DbConfigLoadError({ message }), ); // This same `context` is passed into `buildLocalDbContainerInputs` below as - // `preloadedContext`, since a second `loadCliConfig` call would double-print - // deprecated-config-section warnings; that function returns the same context back verbatim. + // `preloadedContext`; that function returns the same context back verbatim. // `hostnameForValidation` here still feeds the discarded `resolveLocalConfigValues` call below. - const { config, projectEnvValues, loaded, hostname: hostnameForValidation } = context; + const { config, resolvedConfig, hostname: hostnameForValidation } = context; + const document = resolvedConfig.loaded.document ?? {}; // Every duration config field is decoded in this same unconditional pass, before Docker is // touched or the already-running check runs. The parsed values are discarded; only the // fail-fast behavior matters. - const authDocForValidation = asRecord(loaded?.document?.["auth"]); - const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => - resolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.email.max_frequency", () => - parseGoDuration(resolvedEmailForValidation.max_frequency), - ); - yield* wrapDbConfigOverride("auth.email.smtp", () => - resolveAuthEmailSmtp(authDocForValidation, projectEnvValues), + yield* wrapDbConfigParse("auth.email.max_frequency", () => + parseDuration(config.auth.email.max_frequency), ); - const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => - resolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.sms.max_frequency", () => - parseGoDuration(smsForValidation.max_frequency), - ); - const authEnabledForValidation = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, + yield* wrapDbConfigParse("auth.sms.max_frequency", () => + parseDuration(config.auth.sms.max_frequency), ); if ( - authEnabledForValidation && - !smsForValidation.twilio.enabled && - !smsForValidation.twilio_verify.enabled && - !smsForValidation.messagebird.enabled && - !smsForValidation.textlocal.enabled && - !smsForValidation.vonage.enabled && - envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - config.auth.sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ) + config.auth.enabled && + !config.auth.sms.twilio.enabled && + !config.auth.sms.twilio_verify.enabled && + !config.auth.sms.messagebird.enabled && + !config.auth.sms.textlocal.enabled && + !config.auth.sms.vonage.enabled && + config.auth.sms.enable_signup ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } - const gotrueSessionsForValidation = resolveGotrueSessions(config.auth.sessions, projectEnvValues); - if (gotrueSessionsForValidation?.timebox !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.timebox", () => - parseGoDuration(gotrueSessionsForValidation.timebox!), - ); + const { timebox, inactivity_timeout: inactivityTimeout } = config.auth.sessions ?? {}; + if (timebox !== undefined) { + yield* wrapDbConfigParse("auth.sessions.timebox", () => parseDuration(timebox)); } - if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => - parseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), + if (inactivityTimeout !== undefined) { + yield* wrapDbConfigParse("auth.sessions.inactivity_timeout", () => + parseDuration(inactivityTimeout), ); } - yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => - parseGoDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), + yield* wrapDbConfigParse("auth.mfa.phone.max_frequency", () => + parseDuration(config.auth.mfa.phone.max_frequency), ); - yield* wrapDbConfigOverride("auth.rate_limit", () => - resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), + yield* wrapDbConfigParse("auth.passkey", () => resolveGotruePasskeyWebauthn(document)); + yield* wrapDbConfigParse("auth.external", () => + resolveAuthExternalProviders(asRecord(document["auth"]), config.auth.external), ); - yield* wrapDbConfigOverride("auth.jwt_expiry", () => - envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ), + yield* wrapDbConfigParse("storage.file_size_limit", () => + ramInBytes(config.storage.file_size_limit), ); - yield* wrapDbConfigOverride("auth.enable_signup", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_anonymous_sign_ins", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_refresh_token_rotation", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.refresh_token_reuse_interval", () => - envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_manual_linking", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.minimum_password_length", () => - envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.password_requirements", () => - envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues), + yield* wrapDbConfigParse("db.health_timeout", () => + resolveHealthTimeoutSeconds(config.db.health_timeout), ); - yield* wrapDbConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.oauth_server", () => - resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.passkey", () => - resolveGotruePasskeyWebauthn(loaded?.document, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.external", () => - resolveAuthExternalProviders(authDocForValidation, config.auth.external, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.third_party", () => - resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.hook", () => - resolveAuthHooks(authDocForValidation, config.auth.hook, projectEnvValues), - ); - - yield* wrapDbConfigOverride("api.enabled", () => - envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), - ); - yield* wrapDbConfigOverride("api.tls.enabled", () => - envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("api.max_rows", () => - envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), - ); - yield* wrapDbConfigOverride("api.port", () => - envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), - ); - - yield* wrapDbConfigOverride("storage.vector.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_VECTOR_ENABLED", - config.storage.vector.enabled, - "storage.vector.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.s3_protocol.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_ANALYTICS_ENABLED", - config.storage.analytics.enabled, - "storage.analytics.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_namespaces", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", - "storage.analytics.max_namespaces", - config.storage.analytics.max_namespaces, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_tables", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", - "storage.analytics.max_tables", - config.storage.analytics.max_tables, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_catalogs", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", - "storage.analytics.max_catalogs", - config.storage.analytics.max_catalogs, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.vector.max_buckets", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", - "storage.vector.max_buckets", - config.storage.vector.max_buckets, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.vector.max_indexes", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", - "storage.vector.max_indexes", - config.storage.vector.max_indexes, - projectEnvValues, - ), - ); - const imageTransformationSectionPresent = - asRecord(asRecord(loaded?.document?.["storage"])?.["image_transformation"]) !== undefined; - if (imageTransformationSectionPresent) { - yield* wrapDbConfigOverride("storage.image_transformation.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - config.storage.image_transformation?.enabled ?? false, - "storage.image_transformation.enabled", - projectEnvValues, - ), - ); - } - - const localSmtpPortForValidation = yield* wrapDbConfigOverride("local_smtp.port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("local_smtp.smtp_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", - config.local_smtp.smtp_port ?? 0, - "local_smtp.smtp_port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("local_smtp.pop3_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_POP3_PORT", - config.local_smtp.pop3_port ?? 0, - "local_smtp.pop3_port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("analytics.port", () => - envOverridePort( - "SUPABASE_ANALYTICS_PORT", - config.analytics.port, - "analytics.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("analytics.vector_port", () => - envOverridePort( - "SUPABASE_ANALYTICS_VECTOR_PORT", - config.analytics.vector_port ?? 0, - "analytics.vector_port", - projectEnvValues, - ), - ); - - yield* wrapDbConfigOverride("db.pooler.enabled", () => - envOverrideBool( - "SUPABASE_DB_POOLER_ENABLED", - config.db.pooler.enabled, - "db.pooler.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.pooler.port", () => - envOverridePort( - "SUPABASE_DB_POOLER_PORT", - config.db.pooler.port, - "db.pooler.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.pooler.pool_mode", () => - envOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), - ); - yield* wrapDbConfigOverride("db.pooler.default_pool_size", () => - envOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), - ); - yield* wrapDbConfigOverride("db.pooler.max_client_conn", () => - envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), - ); - - yield* wrapDbConfigOverride("edge_runtime.policy", () => - envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), - ); - yield* wrapDbConfigOverride("edge_runtime.inspector_port", () => - envOverridePort( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - config.edge_runtime.inspector_port, - "edge_runtime.inspector_port", - projectEnvValues, - ), - ); - - yield* wrapDbConfigOverride("realtime.ip_version", () => - envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), - ); - yield* wrapDbConfigOverride("realtime.max_header_length", () => - envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), - ); - - yield* wrapDbConfigOverride("db.settings", () => - resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - ); - - yield* wrapDbConfigOverride("realtime.enabled", () => - envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.file_size_limit", () => - ramInBytes( - envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit, - ), - ); - yield* wrapDbConfigOverride("db.health_timeout", () => - resolveHealthTimeoutSeconds( - envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues) ?? - config.db.health_timeout, - ), - ); - - yield* wrapDbConfigOverride("edge_runtime.enabled", () => - envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.network_restrictions.enabled", () => - envOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ), - ); - const sslEnforcementSectionPresent = - asRecord(asRecord(loaded?.document?.["db"])?.["ssl_enforcement"]) !== undefined; - if (sslEnforcementSectionPresent) { - yield* wrapDbConfigOverride("db.ssl_enforcement.enabled", () => - envOverrideBool( - "SUPABASE_DB_SSL_ENFORCEMENT_ENABLED", - config.db.ssl_enforcement?.enabled ?? false, - "db.ssl_enforcement.enabled", - projectEnvValues, - ), - ); - } - const studioEnabledForValidation = yield* wrapDbConfigOverride("studio.enabled", () => - envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ), - ); - const studioPortForValidation = yield* wrapDbConfigOverride("studio.port", () => - envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues), - ); - if (studioEnabledForValidation && studioPortForValidation === 0) { + if (config.studio.enabled && config.studio.port === 0) { return yield* new DbConfigLoadError({ message: "Missing required field in config: studio.port", }); } - const studioApiUrlForValidation = - envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url; - if (studioEnabledForValidation) { + if (config.studio.enabled) { yield* Effect.try({ - try: () => goUrlParse(studioApiUrlForValidation), + try: () => parseUrl(config.studio.api_url), catch: (cause) => new DbConfigLoadError({ message: `Invalid config for studio.api_url: ${cause instanceof Error ? cause.message : String(cause)}`, }), }); } - const localSmtpEnabledForValidation = yield* wrapDbConfigOverride("local_smtp.enabled", () => - envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ), - ); - if (localSmtpEnabledForValidation && localSmtpPortForValidation === 0) { + if (config.local_smtp.enabled && config.local_smtp.port === 0) { return yield* new DbConfigLoadError({ message: "Missing required field in config: local_smtp.port", }); @@ -555,38 +163,43 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // `resolveLocalConfigValues` is the same resolver `buildLocalDbContainerInputs` calls again // below to build the real `values`. Calling it eagerly here too forces its internal - // decode-time throws (auth.captcha, jwt_secret length, signing_keys_path, api.tls cert/key + // decode-time failures (auth.captcha, jwt_secret length, signing_keys_path, api.tls cert/key // reads, auth.external required fields, email/notification template reads) to surface before // the already-running shortcut. The result is discarded; `buildLocalDbContainerInputs` below // re-resolves the real values. - yield* Effect.try({ - try: () => - resolveLocalConfigValues( - config, - hostnameForValidation, - cliSettings.workdir, - projectEnvValues, - loaded?.document, - ), - catch: (cause) => - new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + yield* resolveLocalConfigValues( + config, + hostnameForValidation, + cliSettings.workdir, + document, + ).pipe(Effect.mapError((cause) => new DbConfigLoadError({ message: cause.message }))); + + // Held until this call returns, after any rollback has run. + yield* holdProjectLifecycleLock(context.projectId); // If the db container is already up, tell the caller and stop here. Runs after the config // load/validation above. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - cliSettings.workdir, - Option.getOrUndefined(cliSettings.projectId), - ); - yield* Effect.annotateCurrentSpan("db.already_running", running); + // Probed by the same project id bring-up uses, so a stopped container is removed by the name + // that was inspected. + const dbState = yield* localDbState(spawner, fs, path, cliSettings.workdir, context.projectId); + const running = dbState.exists && dbState.running; + const recoverStopped = dbState.exists && !dbState.running; + yield* Effect.annotateCurrentSpan({ + "db.already_running": running, + "db.recover_stopped": recoverStopped, + }); if (running) { return { status: "already-running" } satisfies StartLocalDatabaseResult; } + // Bitbucket has no named volumes, so removing the container would destroy its data. + if (dbState.exists) { + const inBitbucketPipeline = yield* isBitbucketPipeline(context.projectEnvValues).pipe( + Effect.mapError((error) => new DbConfigLoadError({ message: error.message })), + ); + if (inBitbucketPipeline) { + return yield* dbNotRunningError(localDbContainerId(context.projectId), dbState.status); + } + } // Resolve a relative `--from-backup` against the caller's cwd, captured before any workdir // change. An empty `--from-backup ""` is a normal no-backup start, so treat it as absent @@ -598,10 +211,15 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ? fromBackupFlag : path.join(runtimeInfo.cwd, fromBackupFlag); + // A stopped container's named volume always exists, so a restore is refused before the + // container is removed. + if (recoverStopped && fromBackup !== undefined) { + return yield* backupVolumeExistsError(); + } + // Not running → bring up the container natively. `context` (loaded eagerly above) is threaded // through as `preloadedContext` so this call reuses it instead of calling - // `loadLocalProjectContext` a second time, which would double-print deprecated-config-section - // warnings. + // `loadLocalProjectContext` a second time. const inputs = yield* buildLocalDbContainerInputs( spawner, cliSettings.workdir, @@ -609,7 +227,6 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu runtimeInfo.platform, debug, undefined, - undefined, context, ); const { @@ -626,6 +243,13 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu const filterValue = cliProjectFilterValue(projectId); + // Only the db container: `db start` leaves the project's other services alone. + if (recoverStopped) { + yield* removeContainer(spawner, dbContainerId).pipe( + Effect.withSpan("DbBootstrap.recoverStoppedDatabase"), + ); + } + // Assigned by `startDatabase`'s own pre-create volume-existence check; defaults to // `false` so a rollback triggered by an earlier failure (e.g. network creation) never // deletes a volume this run never confirmed was fresh. @@ -633,8 +257,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // Runs the exact start-database sequence (network -> volume probe -> container create+start // -> health wait -> fresh-volume setup -> `_current_branch`) — shared with `supabase start`, - // see `startDatabase`'s own header. Any failure rolls back via the same `Effect.onError` - // wrapper `supabase start` uses. + // see `startDatabase`'s own header. Any failure rolls back only what `db start` creates. yield* startDatabase(spawner, { fs, path, @@ -658,9 +281,24 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu }, }).pipe( Effect.onError(() => - rollbackStart(spawner, filterValue, isFreshVolume, cliSettings.workdir, debug), + rollbackDatabaseStart(spawner, filterValue, dbContainerId, isFreshVolume, debug), ), ); + // The connect probe returns before Docker's first health probe, and `status` and the + // already-running gate still read Docker health. + yield* awaitDockerHealthSettled(spawner, dbContainerId); + return { status: "started" } satisfies StartLocalDatabaseResult; }); + +/** + * Starts the local Postgres database, or no-ops when it is already running. See this module's + * own header for the full design rationale. + * + * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the caller's cwd + * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with + * no argument. + */ +export const startLocalDatabase = (fromBackupFlag?: string) => + Effect.scoped(startLocalDatabaseInScope(fromBackupFlag)); diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json new file mode 100644 index 0000000000..dce71ff5c6 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "shell-project-id", + "workdir": "" + }, + "dbContainerId": "supabase_db_shell-project-id", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_shell-project-id", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_shell-project-id", + "projectId": "shell-project-id", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "shell-project-id", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "shell-project-id", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://remote.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://remote.example.com", + "dbPort": 58322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "shell-project-id", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json new file mode 100644 index 0000000000..4272d72177 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "abcdefghijklmnopqrst", + "workdir": "" + }, + "dbContainerId": "supabase_db_abcdefghijklmnopqrst", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_abcdefghijklmnopqrst", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_abcdefghijklmnopqrst", + "projectId": "abcdefghijklmnopqrst", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "abcdefghijklmnopqrst", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://shell.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://shell.example.com", + "dbPort": 56322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "abcdefghijklmnopqrst", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json new file mode 100644 index 0000000000..980df2596f --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "abcdefghijklmnopqrst", + "workdir": "" + }, + "dbContainerId": "supabase_db_abcdefghijklmnopqrst", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_abcdefghijklmnopqrst", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_abcdefghijklmnopqrst", + "projectId": "abcdefghijklmnopqrst", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "abcdefghijklmnopqrst", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://remote.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://remote.example.com", + "dbPort": 58322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "abcdefghijklmnopqrst", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json new file mode 100644 index 0000000000..2f86ba9c79 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://base.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": true, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://base.example.com", + "dbPort": 55322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json new file mode 100644 index 0000000000..3d85d0db5a --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json @@ -0,0 +1,602 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STORAGE_ENABLED": "false" + }, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "dotenv_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://dotenv.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STORAGE_ENABLED": "false" + }, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://dotenv.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://dotenv.example.com", + "dbPort": 57322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json new file mode 100644 index 0000000000..a49d200a86 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.011", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://shell.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://shell.example.com", + "dbPort": 56322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index fb4840f27e..530baed442 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -39,6 +39,19 @@ export class DbConfigLoadError extends Data.TaggedError("DbConfigLoadError")<{ } } +type PassthroughLoadFailureTag = + | "ProjectRefReadError" + | "CliConfigValueError" + | "CliConfigFlagConflictError"; + +/** Config-load failures that keep their own tag instead of becoming {@link DbConfigLoadError}. */ +export const isPassthroughLoadFailure = ( + error: E, +): error is Extract => + error._tag === "ProjectRefReadError" || + error._tag === "CliConfigValueError" || + error._tag === "CliConfigFlagConflictError"; + /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class DbConfigLoginRoleNetworkError extends Data.TaggedError( "DbConfigLoginRoleNetworkError", @@ -108,14 +121,19 @@ export class DbConfigUnbanStatusError extends Data.TaggedError("DbConfigUnbanSta } /** - * The linked project's direct database host is unreachable (IPv6-only) and no connection pooler - * is configured. The message text is exact: `"IPv6 is not supported on your current network"`. + * A project's direct database host is unreachable (IPv6-only) and no connection pooler is + * configured. The message text is exact: `"IPv6 is not supported on your current network"`. */ export class DbConfigIpv6Error extends Data.TaggedError("DbConfigIpv6Error")<{ readonly message: string; readonly suggestion?: string; + /** `pooler-url` for a project other than the linked one; `link` (the default) otherwise. */ + readonly remedy?: "link" | "pooler-url"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.remedy === "pooler-url") { + return { ...actionability.dbConnection, suggestion_type: CliSuggestionType.ProvideFlags }; + } // The rendered remediation is "Run supabase link --project-ref to // setup IPv4 connection", so the suggestion is link-shaped even though the // category stays db_connection. @@ -140,6 +158,20 @@ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConn } } +/** The temp login role could not connect with network bans listed and no `--yes` to remove them. */ +export class LinkedDbNetworkBannedError extends Data.TaggedError("LinkedDbNetworkBannedError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase network-bans remove", + }; + } +} + /** * The configured pooler connection string does not match the linked project ref, or its domain * falls outside the active profile (MITM guard). diff --git a/apps/cli/src/command-internal/db-config.errors.unit.test.ts b/apps/cli/src/command-internal/db-config.errors.unit.test.ts new file mode 100644 index 0000000000..4d66e10d3c --- /dev/null +++ b/apps/cli/src/command-internal/db-config.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; +import { DbConfigIpv6Error } from "./db-config.errors.ts"; + +describe("DbConfigIpv6Error", () => { + it.each([undefined, "link"] as const)( + "sends a %s remedy to supabase link and stays a connection failure", + (remedy) => { + expect( + classifyCliErrorActionability(new DbConfigIpv6Error({ message: "m", remedy })), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + has_suggestion: true, + suggestion_type: "link_project", + suggested_command: "supabase link", + }); + }, + ); + + it("asks for flags, with no command to run, when the remedy is a pooler URL", () => { + const classified = classifyCliErrorActionability( + new DbConfigIpv6Error({ message: "m", remedy: "pooler-url" }), + ); + expect(classified).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect(classified).not.toHaveProperty("suggested_command"); + }); +}); 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 338634959e..192a7440c6 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -42,6 +42,12 @@ import { mockCommandSettings, withConfigEnv, } from "../../tests/helpers/command-mocks.ts"; +import { pinnedConfigProvider } from "../../tests/helpers/config-env-pins.ts"; +import { + cliConfigValuesTestLayer, + flagInput, + withHermeticShellTier, +} from "../../tests/helpers/config-values-layer.ts"; import { withReachableDirectHost } from "../../tests/helpers/refuse-invalid-db-hosts.ts"; import { unusedGateway } from "../../tests/helpers/unused-stack.ts"; import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; @@ -65,17 +71,24 @@ import { OutputFlag, ProfileFlag, WorkdirFlag, + YesFlag, } from "./global-flags.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; import { DbConfigResolver } from "./db-config.service.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; +import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { StackApi } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; -import { writeProjectLink } from "./project-link.ts"; -import { projectStateModeLayer, type ProjectStateMode } from "./project-state-mode.ts"; import { selection } from "../../tests/helpers/pins.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver @@ -91,6 +104,15 @@ const mockDbConnection = Layer.succeed(DbConnection, { connect: () => Effect.die("unexpected connect() in --local/--db-url resolver test"), }); +const explicitEnv = ( + env: Readonly> | undefined, +): Record => + Object.fromEntries( + Object.entries(env ?? {}).flatMap(([name, value]): Array<[string, string]> => + value === undefined ? [] : [[name, value]], + ), + ); + function buildResolver( workdir: string, opts: { @@ -100,19 +122,31 @@ function buildResolver( readonly debugLogger?: Layer.Layer; readonly configEnv?: Record; readonly stackApi?: Layer.Layer; - readonly projectStateMode?: ProjectStateMode; + readonly flagPassword?: string; + readonly projectId?: string; + readonly output?: ReturnType; } = {}, ) { + const out = opts.output ?? mockOutput(); const deps = Layer.mergeAll( + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs( + opts.flagPassword === undefined + ? [] + : [flagInput("linkedDb.password", "password", opts.flagPassword)], + ), + ), mockCommandSettings({ workdir, projectHost: opts.projectHost ?? "supabase.co", poolerHost: opts.poolerHost, - projectId: Option.none(), + projectId: Option.fromNullishOr(opts.projectId), }), opts.dbConnection ?? mockDbConnection, opts.debugLogger ?? mockDebugLogger, - mockOutput().layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), mockAnalytics().layer, mockTelemetryRuntime(), mockTty(), @@ -130,13 +164,18 @@ function buildResolver( Layer.provide(BunServices.layer), ), BunServices.layer, - ConfigProvider.layer( - ConfigProvider.fromEnvRecord(opts.configEnv ?? {}, { preserveEmptyStrings: true }), - ), + Layer.effect(ConfigProvider.ConfigProvider, pinnedConfigProvider(explicitEnv(opts.configEnv))), ); + const hermeticValues = Layer.effect( + CliConfigValues, + Effect.map(Effect.service(CliConfigValues), (real) => + withHermeticShellTier(real, explicitEnv(opts.configEnv)), + ), + ).pipe(Layer.provide(cliConfigValuesLayer.pipe(Layer.provide(deps)))); + const depsWithValues = Layer.merge(deps, hermeticValues); return opts.stackApi === undefined - ? dbConfigLayer.pipe(Layer.provide(deps)) - : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(deps, opts.stackApi))); + ? dbConfigLayer.pipe(Layer.provide(depsWithValues)) + : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(depsWithValues, opts.stackApi))); } function withWorkdir(toml?: string) { @@ -152,64 +191,73 @@ function withWorkdir(toml?: string) { }); } -const resolve = ( +const inputFor = ( + target: ResolvedTarget, + overrides: Partial = {}, +): DbConnectionInput => ({ + target, + dnsResolver: "native", + password: Option.none(), + networkBanRemoval: Option.none(), + ...overrides, +}); + +const localTarget: ResolvedTarget = { kind: "local" }; +const dbUrlTarget = (url: string): ResolvedTarget => ({ + kind: "db-url", + url: Redacted.make(url), +}); +const linkedTarget = ( + ref: string, + overrides: Partial> = {}, +): ResolvedTarget => ({ + kind: "linked", + ref, + source: "file", + matchesLinkedRef: true, + ...overrides, +}); +const explicitRefTarget = (ref: string, overrides: { readonly matchesLinkedRef?: boolean } = {}) => + linkedTarget(ref, { source: "flag", ...overrides }); + +const approval = hostedMutationPreapproval.pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, true), Layer.succeed(CliArgs, { args: [] })), + ), +); + +const resolveConnection = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolve(flags); + return yield* resolver.resolveConnection(input); }).pipe( - Effect.provide( - Layer.merge( - buildResolver(workdir, opts), - projectStateModeLayer(opts?.projectStateMode ?? "legacy"), - ), - ), - Effect.provideService( + Effect.provide(buildResolver(workdir, opts)), + Effect.provideServiceEffect( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(opts?.configEnv ?? {}, { preserveEmptyStrings: true }), + pinnedConfigProvider(explicitEnv(opts?.configEnv)), ), ); const resolvePoolerFallback = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolvePoolerFallback(flags); + return yield* resolver.resolvePoolerFallback(input); }).pipe( - Effect.provide( - Layer.merge( - buildResolver(workdir, opts), - projectStateModeLayer(opts?.projectStateMode ?? "legacy"), - ), - ), - Effect.provideService( + Effect.provide(buildResolver(workdir, opts)), + Effect.provideServiceEffect( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(opts?.configEnv ?? {}, { preserveEmptyStrings: true }), + pinnedConfigProvider(explicitEnv(opts?.configEnv)), ), ); -const localFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "local", - dnsResolver: "native", -}; -const dbUrlFlags = (url: string): DbConfigFlags => ({ - dbUrl: Option.some(url), - connType: "db-url", - dnsResolver: "native", -}); -const linkedFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "linked", - dnsResolver: "native", -}; - const assertDbConfigDrift = ( requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, activeFlags: ActiveFlags, @@ -223,12 +271,23 @@ const assertDbConfigDrift = ( }); describe("dbConfigResolver (local + db-url)", () => { + it.effect.each([ + { name: "local", target: localTarget }, + { name: "db-url", target: dbUrlTarget("postgres://alice:password@example.com:6543/appdb") }, + ])("resolves Data API exposure for $name", ({ target }) => + Effect.gen(function* () { + const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); + const result = yield* resolveConnection(dir, inputFor(target)); + expect(result.exposedSchemas).toEqual(["app", "public"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () => Effect.gen(function* () { const dir = yield* withWorkdir( ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -256,7 +315,7 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags, { + return yield* resolveConnection(dir, inputFor(localTarget), { configEnv: { SUPABASE_SERVICES_HOSTNAME: "host.docker.internal" }, }); }).pipe( @@ -271,10 +330,47 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect( + "local mode on a linked checkout carries the --linked pointer in its connect hint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext).toMatchObject({ linkedCheckout: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect( + "local mode with SUPABASE_PROJECT_ID set and no project-ref file carries the --linked pointer in its connect hint", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const r = yield* resolveConnection(dir, inputFor(localTarget), { + projectId: "abcdefghijklmnopqrst", + }); + expect(r.conn.suggestionContext).toMatchObject({ linkedCheckout: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("local mode on an unlinked checkout adds no --linked pointer", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext?.linkedCheckout).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("local mode: falls back to default port/password without a config.toml", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -291,7 +387,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: parses the connection string and percent-decodes the password", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://alice:p%40ss@example.com:6543/appdb")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:p%40ss@example.com:6543/appdb")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -317,9 +416,9 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: a 127.0.0.1 url on the configured db.port is detected as local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:postgres@127.0.0.1:54322/postgres"), + inputFor(dbUrlTarget("postgres://postgres:postgres@127.0.0.1:54322/postgres")), ); }).pipe( Effect.tap((r) => @@ -332,12 +431,72 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect.each([ + "postgres://postgres:postgres@localhost:54322/postgres", + "postgres://postgres:postgres@[::1]:54322/postgres", + ])("db-url mode: the loopback alias %s is local when the services host is 127.0.0.1", (url) => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection(dir, inputFor(dbUrlTarget(url))); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(true); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + + it.effect( + "db-url mode: a loopback address the local stack does not use stays remote on the local port", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres:postgres@127.0.0.2:54322/postgres")), + ); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(false); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + + it.effect( + "db-url mode: a loopback alias on a port the local database does not use stays remote", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres:postgres@localhost:5432/postgres")), + ); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(false); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + inputFor( + dbUrlTarget("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + ), ); }).pipe( Effect.tap((r) => @@ -356,7 +515,10 @@ describe("dbConfigResolver (local + db-url)", () => { const dir = yield* withWorkdir( ["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres@127.0.0.1:54322/postgres")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -374,7 +536,10 @@ describe("dbConfigResolver (local + db-url)", () => { () => { return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:s3cret@ bad host/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:s3cret@ bad host/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -399,7 +564,7 @@ describe("dbConfigResolver (local + db-url)", () => { "postgres://postgres:pw@example.com:5432/postgres?sslmode=verify-full&options=reference%3Dabcdefghijklmnop"; return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags(url)); + return yield* resolveConnection(dir, inputFor(dbUrlTarget(url))); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -415,7 +580,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: accepts a libpq keyword/value DSN", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("host=pg.example.com port=6543 user=admin dbname=app")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("host=pg.example.com port=6543 user=admin dbname=app")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -438,7 +606,10 @@ describe("dbConfigResolver (local + db-url)", () => { // throws. It must surface as a normal parse failure, not an untyped defect. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:p%zz@example.com/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:p%zz@example.com/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -558,7 +729,20 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { dir: string, url: string, stackApi: Layer.Layer = projectStackApi(dir, "running"), - ) => resolve(dir, dbUrlFlags(url), { stackApi }).pipe(Effect.provide(stackBackendLayer("stack"))); + ) => + resolveConnection(dir, inputFor(dbUrlTarget(url)), { stackApi }).pipe( + Effect.provide(stackBackendLayer("stack")), + ); + + it.effect("resolves Data API exposure for the stack-backed local database", () => + Effect.gen(function* () { + const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); + const resolved = yield* resolveConnection(dir, inputFor(localTarget), { + stackApi: projectStackApi(dir, "running"), + }).pipe(Effect.provide(stackBackendLayer("stack"))); + expect(resolved.exposedSchemas).toEqual(["app", "public"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.effect("treats the url printed by `stack status --env` as the local database", () => Effect.gen(function* () { @@ -645,66 +829,34 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { }); describe("dbConfigResolver (linked config ordering)", () => { - it.effect( - "validates the ref-merged config before any network work (Go ParseDatabaseConfig order)", - () => { - // The ref is sourced from the config's top-level project_id; the matching remote block - // sets an unsupported major_version. If validation happened after the connection work, - // `mockDbConnection.connect()` would die first. - const ref = "abcdefghijklmnopqrst"; - return Effect.gen(function* () { - const dir = yield* withWorkdir( - [ - `project_id = "${ref}"`, - "[db]", - "major_version = 15", - `[remotes.${ref.slice(0, 4)}]`, - `project_id = "${ref}"`, - `[remotes.${ref.slice(0, 4)}.db]`, - "major_version = 99", - "", - ].join("\n"), - ); - // The linked ref is sourced via the project-ref resolver's env fallback. - return yield* resolve(dir, linkedFlags, { configEnv: { SUPABASE_PROJECT_ID: ref } }); - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "Failed reading config: Invalid db.major_version: 99.", - ); - } - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }, - ); - - it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { - // The ref file is seeded as a directory (not a file), with no project_id or env fallback, - // to force a real read error. + it.effect("validates the ref-merged config before any network work", () => { + // The ref is sourced from the config's top-level project_id; the matching remote block + // sets an unsupported major_version. If validation happened after the connection work, + // `mockDbConnection.connect()` would die first. + const ref = "abcdefghijklmnopqrst"; return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* withWorkdir(); - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { - recursive: true, - }); - return yield* resolve(dir, linkedFlags); + const dir = yield* withWorkdir( + [ + `project_id = "${ref}"`, + "[db]", + "major_version = 15", + `[remotes.${ref.slice(0, 4)}]`, + `project_id = "${ref}"`, + `[remotes.${ref.slice(0, 4)}.db]`, + "major_version = 99", + "", + ].join("\n"), + ); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref))); }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to load project ref"); - expect(causeText).not.toContain("Cannot find project ref"); + expect(Cause.pretty(exit.cause)).toContain( + "Failed reading config: Invalid db.major_version: 99.", + ); } }), ), @@ -805,17 +957,15 @@ describe("dbConfigResolver (linked config ordering)", () => { ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), linkedRef); - yield* fs.writeFileString( - path.join(dir, "supabase", ".temp", "pooler-url"), - `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, - ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor( + linkedTarget(adHocRef, { + source: "flag", + matchesLinkedRef: false, + poolerUrl: `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + }), + ), { projectHost: "invalid", dbConnection, @@ -971,17 +1121,15 @@ describe("dbConfigResolver (linked config ordering)", () => { ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), linkedRef); - yield* fs.writeFileString( - path.join(dir, "supabase", ".temp", "pooler-url"), - `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, - ); return yield* resolvePoolerFallback( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor( + linkedTarget(adHocRef, { + source: "flag", + matchesLinkedRef: false, + poolerUrl: `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + }), + ), { dbConnection, configEnv: { @@ -1121,25 +1269,19 @@ describe("dbConfigResolver (linked config ordering)", () => { ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), linkedRef); - yield* fs.writeFileString( - path.join(dir, "supabase", ".temp", "pooler-url"), - "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", - ); - return yield* resolvePoolerFallback( - dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(linkedRef), - }, - { - projectHost: "supabase.co", - dbConnection, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "linked-password", - }, + const target = linkedTarget(linkedRef, { + source: "flag", + poolerUrl: + "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + }); + return yield* resolvePoolerFallback(dir, inputFor(target), { + projectHost: "supabase.co", + dbConnection, + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "linked-password", }, - ); + }); }).pipe( Effect.tap((connOpt) => Effect.sync(() => { @@ -1177,11 +1319,10 @@ describe("dbConfigResolver (linked config ordering)", () => { }); }); -// An explicit `--project-ref`/`linkedProjectRef` on a non-ad-hoc `db` command must -// independently unlock the Management API pooler fetch on an IPv4-only network — it must not -// stay confined to the workdir's saved `.temp/pooler-url` the way the plain `--linked` default -// path is. -describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProjectRef)", () => { +// An explicit `--project-ref` must unlock the Management API pooler fetch on an IPv4-only +// network, even when it names the linked project, rather than staying confined to the workdir's +// saved `.temp/pooler-url` the way the plain `--linked` path is. +describe("dbConfigResolver (an explicit --project-ref fetches the pooler config)", () => { it.effect( "an unlinked workdir + explicit --project-ref resolves via the API pooler config, honoring the ambient password with no login-role mint", () => { @@ -1191,7 +1332,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje const requests: Array<{ readonly method: string; readonly path: string }> = []; const dbConnection = Layer.succeed(DbConnection, { connect: () => - Effect.die("unexpected connect() — the ambient password path never verify-connects"), + Effect.die("unexpected connect() — the explicit password path never verify-connects"), }); const fetchMock = Object.assign( (input: string | URL | Request, init?: RequestInit): Promise => { @@ -1241,12 +1382,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje // so there is no `.temp/project-ref` and no `.temp/pooler-url` to reuse. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(ref), - }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1355,19 +1493,19 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), linkedRef); - yield* fs.writeFileString( - path.join(dir, "supabase", ".temp", "pooler-url"), - `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, - ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(targetRef), - }, + inputFor( + linkedTarget(targetRef, { + source: "flag", + matchesLinkedRef: false, + poolerUrl: `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + }), + ), { projectHost: "invalid", dbConnection, + flagPassword: "flag-password", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN, SUPABASE_DB_PASSWORD: "ambient-password", @@ -1381,7 +1519,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${targetRef}`, - password: "ambient-password", + password: "flag-password", database: "postgres", suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", @@ -1407,93 +1545,42 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); it.effect( - "a SUPABASE_PROJECT_ID that differs from the link fetches that ref's pooler instead of using the saved pooler URL", + "an ad-hoc --remote= with no pooler configured suggests the pooler URL, not re-linking", () => { - const linkedRef = "workdirlinkedrefabcd"; - const envRef = "envprojectrefabcdefg"; - + const ref = "adhocnopoolerrefabcd"; const previousFetch = globalThis.fetch; - const requests: Array<{ readonly method: string; readonly path: string }> = []; const dbConnection = Layer.succeed(DbConnection, { - connect: () => - Effect.die("unexpected connect() — the ambient password path never verify-connects"), + connect: () => Effect.die("unexpected connect()"), }); - const fetchMock = Object.assign( - (input: string | URL | Request, init?: RequestInit): Promise => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - const connectionString = `postgres://postgres.${envRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`; - if ( - method === "GET" && - url.pathname === `/v1/projects/${envRef}/config/database/pooler` - ) { - return Promise.resolve( - new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: connectionString, - connectionString, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - } - return Promise.resolve( - new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }), - ); - }, + globalThis.fetch = Object.assign( + (): Promise => + Promise.resolve( + new Response("[]", { status: 200, headers: { "content-type": "application/json" } }), + ), { preconnect: previousFetch.preconnect }, ); - globalThis.fetch = fetchMock; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const dir = yield* withWorkdir(); - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), linkedRef); - yield* fs.writeFileString( - path.join(dir, "supabase", ".temp", "pooler-url"), - `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, - ); - return yield* resolve(dir, linkedFlags, { - projectHost: "invalid", - dbConnection, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "ambient-password", - SUPABASE_PROJECT_ID: envRef, + return yield* resolveConnection( + dir, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), + { + projectHost: "invalid", + dbConnection, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }, - }); + ); }).pipe( - Effect.tap((r) => + Effect.flip, + Effect.tap((error) => Effect.sync(() => { - expect(r.conn).toMatchObject({ - host: "aws-0-us-east-1.pooler.supabase.com", - user: `postgres.${envRef}`, - password: "ambient-password", + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + remedy: "pooler-url", + suggestion: + "Pass the project's connection pooler URL with --remote= to connect over IPv4.", }); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${envRef}/config/database/pooler` }, - ]); }), ), Effect.ensuring( @@ -1508,141 +1595,17 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); it.effect( - "an explicit --project-ref with no password mints a login role and clears network bans after three failed temp-role connects", + "the plain --linked path (no --project-ref) keeps the IPv6 error when the API has no pooler config either", () => { - const ref = "banrecoveryrefabcdef"; - - const previousFetch = globalThis.fetch; + const ref = "plainlinkedrefabcdef"; const requests: Array<{ readonly method: string; readonly path: string }> = []; - let connectAttempts = 0; - 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 dbConnection = Layer.succeed(DbConnection, { - connect: () => - Effect.suspend(() => { - connectAttempts += 1; - return connectAttempts <= 3 - ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) - : Effect.succeed(session); - }), - }); - const json = (status: number, body: unknown) => - Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ); - const fetchMock = Object.assign( - (input: string | URL | Request, init?: RequestInit): Promise => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { - return json(200, [ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/cli/login-role`) { - return json(201, { - role: "cli_login_role", - password: "temporary-role-password", - ttl_seconds: 3600, - }); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/network-bans/retrieve`) { - return json(200, { banned_ipv4_addresses: ["203.0.113.7"] }); - } - if (method === "DELETE" && url.pathname === `/v1/projects/${ref}/network-bans`) { - return json(200, {}); - } - return json(404, { message: "unexpected request" }); - }, - { preconnect: previousFetch.preconnect }, - ); - - globalThis.fetch = fetchMock; - - return Effect.gen(function* () { - const retries = yield* Queue.unbounded(); - const debugLogger = Layer.succeed(DebugLogger, { - debug: (message) => - message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, - http: () => Effect.void, - }); - const dir = yield* withWorkdir(); - const resolved = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { - projectHost: "invalid", - dbConnection, - debugLogger, - configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, - }, - ).pipe(Effect.forkChild({ startImmediately: true })); - for (let failedConnects = 1; failedConnects <= 3; failedConnects++) { - yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(resolved))); - yield* TestClock.adjust("10 seconds"); - } - return yield* Fiber.join(resolved); - }).pipe( - Effect.tap((r) => - Effect.sync(() => { - expect(r.conn).toMatchObject({ - host: "aws-0-us-east-1.pooler.supabase.com", - user: `cli_login_role.${ref}`, - password: "temporary-role-password", - }); - expect(connectAttempts).toBe(4); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - { method: "POST", path: `/v1/projects/${ref}/cli/login-role` }, - { method: "POST", path: `/v1/projects/${ref}/network-bans/retrieve` }, - { method: "DELETE", path: `/v1/projects/${ref}/network-bans` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); - }), - ), - Effect.ensuring( - Effect.sync(() => { - globalThis.fetch = previousFetch; - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }, - ); - - it.effect( - "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", - () => { - const ref = "plainlinkedrefabcdef"; return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json([]) + : undefined, + ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* withWorkdir( @@ -1650,11 +1613,17 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - return yield* resolve(dir, linkedFlags, { projectHost: "invalid" }); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref)), { + projectHost: "invalid", + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -1663,6 +1632,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { expect(error.value).toMatchObject({ + remedy: "link", suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, }); } @@ -1700,7 +1670,7 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, localFlags); + const r = yield* resolveConnection(dir, inputFor(localTarget)); expect(r.isLocal).toBe(true); noApiCalls(requests, { local: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1711,7 +1681,10 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, dbUrlFlags("postgres://alice:secret@example.com:6543/appdb")); + const r = yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:secret@example.com:6543/appdb")), + ); expect(r.isLocal).toBe(false); noApiCalls(requests, { "db-url": true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1729,9 +1702,9 @@ describe("dbConfigResolver (Management API calls)", () => { : undefined, ); const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection: noDirectConnect, @@ -1761,40 +1734,6 @@ describe("dbConfigResolver (Management API calls)", () => { }, ); - it.effect("an empty --project-ref still fetches the pooler config for the linked project", () => { - const ref = "emptyprojectrefabcde"; - const requests: Array<{ readonly method: string; readonly path: string }> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, (method, path) => - method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` - ? Response.json(primaryPoolerConfigs(ref)) - : undefined, - ); - const dir = yield* withWorkdir(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - const r = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some("") }, - { - projectHost: "invalid", - dbConnection: noDirectConnect, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "ambient-password", - }, - }, - ); - expect(r.conn).toMatchObject({ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432 }); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": "" }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }); - it.effect("an empty --password still mints a login role", () => { const ref = "emptypasswordrefabcd"; const requests: Array<{ readonly method: string; readonly path: string }> = []; @@ -1820,9 +1759,11 @@ describe("dbConfigResolver (Management API calls)", () => { return undefined; }); const dir = yield* withWorkdir(); - const r = yield* resolve( + const r = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("") }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + password: Option.some(""), + }), { projectHost: "invalid", dbConnection: Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }), @@ -1896,9 +1837,11 @@ describe("dbConfigResolver (Management API calls)", () => { http: () => Effect.void, }); const dir = yield* withWorkdir(); - const resolved = yield* resolve( + const resolved = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), { projectHost: "invalid", dbConnection, @@ -1966,11 +1909,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { return undefined; }); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(ref)), { + dbConnection: connects, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); expect(Option.isSome(pooler)).toBe(true); if (Option.isSome(pooler)) { expect(pooler.value).toMatchObject({ @@ -1998,7 +1940,7 @@ describe("dbConfigResolver (container pooler fallback)", () => { const dir = yield* withWorkdir(); const pooler = yield* resolvePoolerFallback( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") }, + inputFor(explicitRefTarget(ref), { password: Option.some("secret") }), { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, ); expect(pooler).toEqual( @@ -2018,11 +1960,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { : undefined, ); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(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 }); @@ -2034,7 +1975,7 @@ describe("dbConfigResolver (container pooler fallback)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback(dir, localFlags); + const pooler = yield* resolvePoolerFallback(dir, inputFor(localTarget)); expect(Option.isNone(pooler)).toBe(true); expect(requests).toEqual([]); assertFallbackDrift(requests, { local: true }); @@ -2042,37 +1983,6 @@ describe("dbConfigResolver (container pooler fallback)", () => { }); }); -describe("dbConfigResolver (stack-mode link document)", () => { - it.effect( - "the pooler fallback reuses the pooler URL saved in .link.json without any API call", - () => { - const ref = "stackmodelinkedrefab"; - return Effect.gen(function* () { - const dir = yield* withWorkdir(); - yield* writeProjectLink(dir, { - project: { ref }, - active_branch: { - ref, - pooler_url: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - }, - fetchedAt: "2026-01-01T00:00:00.000Z", - }); - - const pooler = yield* resolvePoolerFallback(dir, linkedFlags, { - projectStateMode: "stack", - configEnv: { SUPABASE_DB_PASSWORD: "linked-password" }, - }); - - expect(Option.getOrUndefined(pooler)).toMatchObject({ - host: "aws-0-us-east-1.pooler.supabase.com", - user: `postgres.${ref}`, - password: "linked-password", - }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }, - ); -}); - describe("resolveLinkedConn libpq TLS", () => { const ref = "cli2753tlsabcdefghij"; const directHost = `db.${ref}.invalid`; @@ -2093,6 +2003,7 @@ describe("resolveLinkedConn libpq TLS", () => { make: CommandPlatformApi.pipe(Effect.provide(api.layer)), }), Layer.succeed(DbConnection, { connect: () => Effect.die("unexpected connect()") }), + cliConfigValuesTestLayer, ); const projectWorkdir = (dotenv: ReadonlyArray) => @@ -2125,7 +2036,7 @@ describe("resolveLinkedConn libpq TLS", () => { "supabase.com", "native", opts.password ?? Option.some("secret"), - { poolerUrl: Option.fromNullishOr(opts.poolerUrl) }, + { removeNetworkBans: false, poolerUrl: Option.fromNullishOr(opts.poolerUrl) }, ); }), ).pipe(Effect.scoped); @@ -2295,3 +2206,489 @@ describe("resolveLinkedConn libpq TLS", () => { }).pipe(Effect.provide(linkedConnLayer)), ); }); + +describe("dbConfigResolver.resolveConnection", () => { + const failureText = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; + 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" }), + }; + + it.effect("local target connects to the local stack and announces it", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir( + ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), + ); + const r = yield* resolveConnection(dir, inputFor({ kind: "local" }), { output }); + expect(r.conn).toMatchObject({ host: "127.0.0.1", port: 55555, password: "hunter2" }); + expect(r.isLocal).toBe(true); + expect(r.target).toEqual({ kind: "local" }); + expect(output.stderrText).toBe("Target: local stack (127.0.0.1:55555)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("db-url target announces the host and never the credentials", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@db.example.com:6543/appdb"); + const r = yield* resolveConnection(dir, inputFor({ kind: "db-url", url }), { output }); + expect(r.conn).toMatchObject({ host: "db.example.com", port: 6543, user: "alice" }); + expect(r.isLocal).toBe(false); + expect(output.stderrText).toBe("Target: custom Postgres (db.example.com:6543)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("a db-url that fails to parse echoes the url with the password redacted", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@:notaport/appdb"); + const exit = yield* Effect.exit(resolveConnection(dir, inputFor({ kind: "db-url", url }))); + expect(failureText(exit)).toContain("DbConfigParseUrlError"); + expect(failureText(exit)).not.toContain("secret"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + const directTargets: ReadonlyArray = [ + ["local", { kind: "local" }], + [ + "db-url", + { kind: "db-url", url: Redacted.make("postgres://u:p@db.example.com:5432/postgres") }, + ], + ]; + for (const [label, target] of directTargets) { + it.effect(`rejects --password for a ${label} target before announcing it`, () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const exit = yield* Effect.exit( + resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { output }), + ); + expect(failureText(exit)).toContain("TargetPasswordConflictError"); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect(`accepts an empty --password for a ${label} target`, () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + yield* resolveConnection(dir, inputFor(target, { password: Option.some("") })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + } + + it.effect("a linked target that matches the linked workdir reuses the saved pooler URL", () => { + const ref = "savedpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + const output = mockOutput(); + const target = linkedTarget(ref, { + poolerUrl: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + }); + const r = yield* resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { + projectHost: "invalid", + output, + }); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + password: "pw", + }); + expect(r.ref).toEqual(Option.some(ref)); + expect(r.target).toEqual(target); + expect(requests).toEqual([]); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect( + "a linked target with no saved pooler URL fetches the pooler config from the API", + () => { + const ref = "bareremoterefabcdefg"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const dir = yield* withWorkdir(); + const r = yield* resolveConnection( + dir, + inputFor(linkedTarget(ref), { password: Option.some("pw") }), + { projectHost: "invalid", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + password: "pw", + }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + assertDbConfigDrift(requests, { remote: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); + + const unusableTokens: ReadonlyArray]> = [ + ["no access token", { SUPABASE_NO_KEYRING: "1" }], + [ + "a malformed access token", + { SUPABASE_NO_KEYRING: "1", SUPABASE_ACCESS_TOKEN: "not-a-token" }, + ], + ]; + for (const [label, configEnv] of unusableTokens) { + it.effect( + `a linked target with a password and ${label} keeps the IPv6 error and makes no API request`, + () => { + const ref = "tokenlessrefabcdefgh"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(linkedTarget(ref), { password: Option.some("pw") }), + { projectHost: "invalid", configEnv }, + ); + }).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + remedy: "link", + suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + }); + expect(requests).toEqual([]); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ); + }, + ); + } + + it.effect("a linked target that is not the linked workdir ignores the saved pooler URL", () => { + const linkedRef = "abcdefghijklmnopqrst"; + const ref = "otherpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + const r = yield* resolveConnection( + dir, + inputFor( + linkedTarget(ref, { + source: "flag", + matchesLinkedRef: false, + poolerUrl: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@stale.pooler.supabase.com:6543/postgres`, + }), + { password: Option.some("pw") }, + ), + { projectHost: "invalid", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + describe("network bans on the temporary login role", () => { + const ref = "banrecoveryrefabcdef"; + const bannedRequests = ( + requests: Array<{ readonly method: string; readonly path: string }>, + bans: ReadonlyArray, + failing?: "retrieve" | "delete", + ) => + stubManagementApi(requests, (method, path) => { + 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 }, + ); + } + if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { + return failing === "retrieve" + ? Response.json({ message: "listing broke" }, { status: 500 }) + : Response.json({ banned_ipv4_addresses: bans }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return failing === "delete" + ? Response.json({ message: "removal broke" }, { status: 500 }) + : Response.json({}); + } + return undefined; + }); + const failingConnect = (failures: number) => { + const attempts = { count: 0 }; + const layer = Layer.succeed(DbConnection, { + connect: () => + Effect.suspend(() => { + attempts.count += 1; + return attempts.count <= failures + ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) + : Effect.succeed(session); + }), + }); + return { attempts, layer }; + }; + const forkResolve = ( + input: DbConnectionInput, + connection: Layer.Layer, + output?: ReturnType, + ) => + Effect.gen(function* () { + const retries = yield* Queue.unbounded(); + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => + message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, + http: () => Effect.void, + }); + const dir = yield* withWorkdir(); + const fiber = yield* resolveConnection(dir, input, { + projectHost: "invalid", + dbConnection: connection, + debugLogger, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + output, + }).pipe(Effect.forkChild({ startImmediately: true })); + const advance = (retryMessages: number) => + Effect.gen(function* () { + for (let seen = 1; seen <= retryMessages; seen++) { + yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(fiber))); + yield* TestClock.adjust("1 minute"); + } + }); + return { fiber, advance }; + }); + + it.effect.each([{ matchesLinkedRef: false }, { matchesLinkedRef: true }])( + "reports the listed bans and names the project once the retries run out and removes nothing (matchesLinkedRef: $matchesLinkedRef)", + ({ matchesLinkedRef }) => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7", "198.51.100.2"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + message: expect.stringContaining("203.0.113.7, 198.51.100.2"), + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip 203.0.113.7,198.51.100.2 --experimental --yes --remote=${ref}`, + }), + ), + ); + expect(connection.attempts.count).toBe(9); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); + + it.effect("connects without removing bans that are not blocking the caller", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("keeps retrying without removing anything when no ban is listed", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("removes the bans and says so once the caller holds a --yes approval", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + const output = mockOutput(); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + output, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests).toContainEqual({ + method: "DELETE", + path: `/v1/projects/${ref}/network-bans`, + }); + expect(output.stderrText).toContain("Removing network bans (203.0.113.7)...\n"); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("does not remove bans for an approval obtained by prompting", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + // The brand is type-only; only the consent module mints tokens in production. + const promptApproval = Option.some({ + action: "Push", + via: "prompt", + } as HostedMutationConsent); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: promptApproval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect( + "reports the removal failure, not a --yes hint, when removal keeps failing under --yes", + () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"], "delete"); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigUnbanStatusError", + status: 500, + message: expect.stringContaining("unexpected remove bans status 500"), + }), + ), + ); + expect(failureText(exit)).not.toContain("--yes"); + expect(connection.attempts.count).toBe(9); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); + + it.effect("reports the connection error when listing keeps failing under --yes", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, [], "retrieve"); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(failureText(exit)).not.toContain("--yes"); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("keeps a failed removal when a later listing fails under --yes", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + let listCalls = 0; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => { + 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 }, + ); + } + if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { + listCalls += 1; + return listCalls === 1 + ? Response.json({ banned_ipv4_addresses: ["203.0.113.7"] }) + : Response.json({ message: "listing broke" }, { status: 500 }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return Response.json({ message: "removal broke" }, { status: 500 }); + } + return undefined; + }); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigUnbanStatusError"); + expect(listCalls).toBeGreaterThan(1); + }).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 ee59d1bdc8..f42092c3fe 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -1,7 +1,6 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; import { - Config, Crypto, Duration, Effect, @@ -10,13 +9,17 @@ import { Option, Path, Predicate, + Redacted, Struct, } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { type CliConfigKeyOrigin } from "../config/cli-config-key.ts"; +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues, type ResolvedCliConfig } from "../config/cli-config-values.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { RemoteTarget, PROJECT_REF_PATTERN } from "../config/remote-target.service.ts"; +import { projectRefPicker } from "../config/project-ref.layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -24,6 +27,7 @@ import { ProfileFlag, WorkdirFlag, } from "./global-flags.ts"; +import { CommandTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; @@ -45,38 +49,35 @@ import { redactConnectionString, } from "./db-config.parse.ts"; import { DbConfigResolver, type DbConfigError } from "./db-config.service.ts"; -import { loadProjectEnv, readDbToml } from "./db-config.toml-read.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { loadProjectEnvValues, readDbToml } from "./db-config.toml-read.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; 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, type OperationEntry } from "./command-permissions/model.ts"; - -const linkedEntries: ReadonlyArray> = [ - { - operationId: "v1-create-login-role", - kind: "required", - when: [{ flag: "password", present: false }], - context: - "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: [{ flag: "project-ref" }], - context: - "the direct database host is unreachable (IPv4-only network) and there is no usable saved pooler URL (supabase/.temp/pooler-url, or supabase/.link.json in stack mode), or a SUPABASE_PROJECT_ID ref differs from the link", - }, - { - operationId: "v1-get-pooler-config", - kind: "required", - 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 pooler URL (supabase/.temp/pooler-url, or supabase/.link.json in stack mode), or a SUPABASE_PROJECT_ID ref differs from the link", - }, +import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; +import { isCheckoutLinked } from "../shared/config/link-snapshot.ts"; +import { rejectTargetPassword } from "./target/target-password.ts"; + +const loginRoleEntry = { + operationId: "v1-create-login-role", + kind: "required", + when: [{ flag: "password", present: false }], + context: + "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)", +} as const satisfies Omit; + +const poolerConfigEntry = { + operationId: "v1-get-pooler-config", + kind: "required", + context: + "the direct database host is unreachable (IPv4-only network) and there is no usable saved pooler URL (supabase/.temp/pooler-url, or supabase/.link.json in stack mode) for the target ref", +} as const satisfies Omit; + +const networkBanEntries = [ { operationId: "v1-list-all-network-bans", kind: "best-effort", @@ -91,20 +92,34 @@ const linkedEntries: ReadonlyArray> = [ context: "after three failed attempts to connect through the pooler as the temporary login role", }, +] as const satisfies ReadonlyArray>; + +const linkedEntries: ReadonlyArray> = [ + loginRoleEntry, + poolerConfigEntry, + ...networkBanEntries, + ...projectRefPicker.operations, ]; /** * 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. + * linked path some other way, such as `db diff --from linked`. */ 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` fetches the - * pooler config without `--project-ref`, so that command declares its own entry. + * The calls {@link dbConfigLinked} makes for a project a non-selector flag names, such as + * `db diff --from=`. + */ +export const dbConfigNamedProject = block("db-config", [ + loginRoleEntry, + poolerConfigEntry, + ...networkBanEntries, +]); + +/** + * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. The container + * retry that `db dump` and `db pull` run is {@link dbConfigPoolerFallback}. */ export const dbConfig = block( "db-config", @@ -119,7 +134,7 @@ const poolerFallbackEntries: ReadonlyArray> = [ operationId: "v1-get-pooler-config", kind: "required", context: - "the command's database work fails with an IPv6 connectivity error against the direct database host, and there is no usable saved pooler URL (supabase/.temp/pooler-url, or supabase/.link.json in stack mode), or a SUPABASE_PROJECT_ID ref differs from the link", + "the command's database work fails with an IPv6 connectivity error against the direct database host, and there is no usable saved pooler URL (supabase/.temp/pooler-url, or supabase/.link.json in stack mode) for the target ref", }, { operationId: "v1-create-login-role", @@ -163,6 +178,13 @@ export const dbConfigPoolerFallback = block( })), ); +/** + * Only a `--yes`-sourced approval may clear bans; a prompt answer never does, since removal clears + * every listed address. + */ +const canRemoveNetworkBans = (input: DbConnectionInput): boolean => + Option.exists(input.networkBanRemoval, (approval) => approval.via === "yes"); + const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; @@ -190,7 +212,12 @@ const unbanErrorMapper = mapHttpError({ statusMessage: (status, body) => `unexpected remove bans status ${status}: ${body}`, }); -/** Compares against the resolved local services hostname, not a hard-coded loopback. */ +const isLoopbackHost = (host: string): boolean => + host === "localhost" || host === "::1" || (net.isIPv4(host) && host.startsWith("127.")); + +const LOCAL_STACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "::1"]); + +/** The stack's own spellings of its host count as local; the port must match too. */ function isLocalDatabase( host: string, localHost: string, @@ -198,17 +225,14 @@ function isLocalDatabase( dbPort: number, shadowPort: number, ): boolean { - return host === localHost && (port === dbPort || port === shadowPort); + const sameHost = + host === localHost || (LOCAL_STACK_HOSTS.has(host) && LOCAL_STACK_HOSTS.has(localHost)); + return sameHost && (port === dbPort || port === shadowPort); } /** Stack databases listen on loopback, so only loopback or services-hostname URLs can match one. */ function mayBeStackDatabaseHost(host: string, localHost: string): boolean { - return ( - host === localHost || - host === "localhost" || - host === "::1" || - (net.isIPv4(host) && host.startsWith("127.")) - ); + return host === localHost || isLoopbackHost(host); } /** Best-effort TCP reachability probe with a 5s timeout. */ @@ -230,9 +254,8 @@ const tcpReachable = (host: string, port: number): Effect.Effect => // POST /v1/projects/{ref}/cli/login-role → mint a temporary postgres role. The Management API // client is built lazily via `CommandPlatformApiFactory.make` (not the eager -// `CommandPlatformApi` stack), so the access token is resolved only here — when a temp role is -// actually minted. `--linked --password` returns before reaching this, so it stays auth-free; -// `--local`/`--db-url` never build this layer at all. +// `CommandPlatformApi` stack), so the access token is resolved only when a call needs it: this +// mint or the pooler-config fallback. `--local`/`--db-url` never build this layer at all. const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( ref: string, conn: PgConnInput, @@ -247,26 +270,49 @@ const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( return { ...conn, user: role.role, password: role.password }; }); -const listAndUnban = Effect.fnUntraced(function* (ref: string) { +const listBans = Effect.fnUntraced(function* (ref: string) { const api = yield* (yield* CommandPlatformApiFactory).make; const bans = yield* api.v1.listAllNetworkBans({ ref }).pipe(Effect.catch(listBansErrorMapper)); - const addrs = bans.banned_ipv4_addresses; - if (addrs.length === 0) return; + return bans.banned_ipv4_addresses; +}); + +const removeBans = Effect.fnUntraced(function* (ref: string, addrs: ReadonlyArray) { + const output = yield* Output; + const api = yield* (yield* CommandPlatformApiFactory).make; + yield* output.raw(`Removing network bans (${addrs.join(", ")})...\n`, "stderr"); yield* api.v1 .deleteNetworkBans({ ref, ipv4_addresses: [...addrs], requester_ip: false }) .pipe(Effect.catch(unbanErrorMapper)); }); -// Verify-connect with backoff while the pooler refreshes the temp password. On attempt ≥ 3, -// clear any network ban on the requester. +const networkBannedError = (ref: string, addrs: ReadonlyArray) => + new Errors.LinkedDbNetworkBannedError({ + message: `Network bans (${addrs.join(", ")}) may be blocking the connection. Removing them clears every listed address, so it needs --yes.`, + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip ${addrs.join(",")} --experimental --yes --remote=${ref}`, + }); + +interface BanState { + readonly listed: ReadonlyArray; + readonly failure: Option.Option; +} + +const NO_BANS: BanState = { listed: [], failure: Option.none() }; + +// Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look +// for network bans on the project: with approval they are removed and a failed removal is what the +// run reports if the retries run out; without it the last listing is reported instead. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", + removeNetworkBans: boolean, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; - const attempt = (n: number): Effect.Effect => + const attempt = ( + n: number, + bans: BanState, + ): Effect.Effect => // The temp-role probe always targets the remote Supavisor pooler, so it connects with TLS // and honors `--dns-resolver`. Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( @@ -275,35 +321,60 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { + if (Option.isSome(bans.failure)) return Effect.fail(bans.failure.value); return Effect.fail( - new Errors.DbConfigConnectTempRoleError({ - message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, - }), + bans.listed.length > 0 + ? networkBannedError(ref, bans.listed) + : new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_ENV_VAR, + }), ); } - // From the 3rd failure onward, clear any network ban on the requester. Uses a - // deterministic backoff curve rather than jittered, since jitter only matters under - // concurrent pooler refreshes. - const unban = n >= 3 ? listAndUnban(ref) : Effect.void; + // Deterministic backoff rather than jittered, since jitter only matters under concurrent + // pooler refreshes. const delayMs = Math.min( Duration.toMillis(BACKOFF_INITIAL) * 1.5 ** (n - 1), Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { - // A transient ban-list/unban failure must not propagate out of the retry loop; log - // it to --debug, then discard. - yield* unban.pipe( - Effect.tapError((banError) => debug.debug(banError.message)), - Effect.ignore, - ); + // A ban-list/unban failure must not abort the retry loop: it is logged to --debug and a + // failed removal is kept so the final error names it. + const nextBans = + n < 3 + ? bans + : yield* listBans(ref).pipe( + Effect.flatMap( + ( + addrs, + ): Effect.Effect< + BanState, + DbConfigError, + CommandPlatformApiFactory | Output + > => + addrs.length > 0 && removeNetworkBans + ? removeBans(ref, addrs).pipe( + Effect.as(NO_BANS), + Effect.catch((removeError) => + debug.debug(removeError.message).pipe( + Effect.as({ + listed: addrs, + failure: Option.some(removeError), + }), + ), + ), + ) + : Effect.succeed({ listed: addrs, failure: Option.none() }), + ), + Effect.catch((listError) => debug.debug(listError.message).pipe(Effect.as(bans))), + ); yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); - return yield* attempt(n + 1); + return yield* attempt(n + 1, nextBans); }); }), ); - return yield* attempt(1); + return yield* attempt(1, NO_BANS); }); const LIBPQ_ENV_MESSAGE = @@ -334,34 +405,93 @@ const poolerConfigFrom = Effect.fnUntraced(function* ( return Option.none(); }); -// Resolve the DB password with this precedence: `--password` flag → `SUPABASE_DB_PASSWORD` -// shell env → project `.env*` value. `loadProjectEnv` already excludes shell-set keys, so the -// shell value still wins over the file. `workdir` is an explicit parameter (never -// `CommandSettings.workdir`) so callers whose real workdir has diverged from that cwd-walked -// value (e.g. `bootstrap`, after its own `process.chdir`) still resolve against the correct -// directory. -const resolveDbPassword = Effect.fnUntraced(function* ( - passwordFlag: Option.Option, +const noticedWithheldPassword = new WeakSet(); + +const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { + switch (origin.tier) { + case "flag": + return `--${origin.flag}`; + case "shell": + return `${origin.envName} (environment)`; + case "projectEnv": + return origin.file === undefined ? origin.envName : `${origin.envName} (${origin.file})`; + default: + return origin.tier; + } +}; + +const loadFailureToDbConfigError = ( + error: E, +) => + Errors.isPassthroughLoadFailure(error) + ? error + : new Errors.DbConfigLoadError({ message: error.message }); + +/** + * Resolves the database password through `CliConfigValues` (`--password` > shell env > project + * `.env*`). `workdir` is an explicit parameter (never `CommandSettings.workdir`) so callers whose + * real workdir has diverged from that cwd-walked value (e.g. `bootstrap`, after its own + * `process.chdir`) resolve against the correct directory. `explicit` is a password the caller + * already holds, such as the one `bootstrap` just set on a new project. An empty result means "mint + * a temporary login role". + */ +export const resolveLinkedPassword = Effect.fn("DbConfig.resolveLinkedPassword")(function* ( + ref: string, workdir: string, + explicit: Option.Option, + hasPasswordFlag = false, ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - const ambientPassword = yield* Config.option(Config.String("SUPABASE_DB_PASSWORD")); - return ( - Option.getOrUndefined(passwordFlag) ?? - Option.getOrUndefined(ambientPassword) ?? - projectEnv["SUPABASE_DB_PASSWORD"] ?? - "" + const debug = yield* DebugLogger; + if (Option.isSome(explicit)) { + yield* debug.debug("Using the database password supplied by the caller..."); + return explicit.value; + } + const output = yield* Output; + const resolvedConfig = yield* (yield* CliConfigValues) + .load({ workdir, projectRef: Option.some(ref) }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + const resolved = yield* resolvedConfig + .get(CliConfigKeys.linkedDb.password) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + const withheld = resolvedConfig.withheldEnv.find( + (entry) => entry.path === CliConfigKeys.linkedDb.password.path, ); + if ( + withheld !== undefined && + resolved.origin.tier !== "flag" && + !noticedWithheldPassword.has(resolvedConfig) + ) { + noticedWithheldPassword.add(resolvedConfig); + yield* output.warn( + `Not sending ${withheld.envName} to ${withheld.targetRef}: this directory is linked to ${withheld.linkedRef}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN).${ + hasPasswordFlag ? ` Pass --password to use a password for ${withheld.targetRef}.` : "" + }`, + ); + } + if (Option.isNone(resolved.value)) { + yield* debug.debug("No database password found; using a temporary login role..."); + return ""; + } + yield* debug.debug(`Using database password from ${describePasswordOrigin(resolved.origin)}...`); + return resolved.value.value; }); +/** The API's primary pooler URL; any failure, a missing access token included, means none. */ +const fetchApiPoolerUrl = Effect.fn("DbConfig.fetchApiPoolerUrl")( + function* (ref: string) { + const api = yield* (yield* CommandPlatformApiFactory).make; + const configs = yield* api.v1.getPoolerConfig({ ref }); + return configs.find((config) => config.database_type === "PRIMARY")?.connection_string; + }, + Effect.orElseSucceed(() => undefined), +); + /** * Resolve the IPv4 transaction pooler connection for `ref`. Returns `None` when no pooler URL is * configured or it fails validation, so the caller can keep the original error. With a password, * uses it directly; without one, mints a temp login role and verify-connects through the pooler. * - * `workdir`/`poolerHost` are explicit parameters (see {@link resolveDbPassword}). + * `workdir`/`poolerHost` are explicit parameters (see {@link resolveLinkedPassword}). */ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ref: string, @@ -371,64 +501,48 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( password: string, // The pooler URL the link recorded for `ref`. savedPoolerUrl: Option.Option, - // The container-fallback path falls back to the Management API's primary pooler config when - // no pooler URL is saved; the resolve-time IPv6 path uses the saved URL only and - // errors otherwise, so this defaults off. fetchFromApi = false, - // For an ad-hoc `--project-id` ref the saved pooler URL belongs to the + // For an ad-hoc `--project-ref` the saved pooler URL belongs to the // (possibly different) linked workdir, so ignore it and resolve the pooler for // `ref` from the Management API instead. ignoreSavedUrl = false, resolveVaultSecrets = true, + removeNetworkBans: boolean, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const debug = yield* DebugLogger; // Linked-path read: merge the `[remotes.]` override, so this matches the ref-aware read - // on the main linked branch rather than validating base config. For an ad-hoc `--project-id` - // ref, skip the saved workdir pooler URL because it belongs to the linked project, not - // necessarily the explicit ref. + // on the main linked branch rather than validating base config. For an ad-hoc `--project-ref`, + // skip the saved workdir pooler URL because it belongs to the linked project, not necessarily + // the explicit ref. yield* readDbToml(fs, path, workdir, ref, { resolveVaultSecrets, }); let connectionString = ignoreSavedUrl ? undefined : Option.getOrUndefined(savedPoolerUrl); if (connectionString === undefined) { if (!fetchFromApi) return Option.none(); - // No saved pooler URL → fetch the primary pooler config from the Management API. Any API - // failure means "no fallback", so swallow it to `None`. - const api = yield* (yield* CommandPlatformApiFactory).make; - const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); - if (Option.isNone(configsOpt)) return Option.none(); - const primary = configsOpt.value.find((config) => config.database_type === "PRIMARY"); - if (primary === undefined) return Option.none(); - connectionString = primary.connection_string; + connectionString = yield* fetchApiPoolerUrl(ref); + if (connectionString === undefined) return Option.none(); } let pooler = Option.none(); if (connectionString !== undefined) { pooler = yield* poolerConfigFrom(ref, connectionString, poolerHost); } if (Option.isNone(pooler) && fetchFromApi) { - const api = yield* (yield* CommandPlatformApiFactory).make; - const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); - if (Option.isSome(configsOpt)) { - const primary = configsOpt.value.find((config) => config.database_type === "PRIMARY"); - if (primary !== undefined) { - pooler = yield* poolerConfigFrom(ref, primary.connection_string, poolerHost); - } + const apiUrl = yield* fetchApiPoolerUrl(ref); + if (apiUrl !== undefined) { + pooler = yield* poolerConfigFrom(ref, apiUrl, poolerHost); } } if (Option.isNone(pooler)) return Option.none(); const poolerConn = pooler.value; - if (password.length > 0) { - yield* debug.debug("Using database password from env var..."); - return Option.some({ ...poolerConn, password }); - } + if (password.length > 0) return Option.some({ ...poolerConn, password }); // Mint a temp role; preserve Supavisor's `.` tenant suffix. const originalUser = poolerConn.user; const withRole = yield* initLoginRole(ref, poolerConn); const finalUser = originalUser.endsWith(`.${ref}`) ? `${withRole.user}.${ref}` : withRole.user; const tempConn = { ...withRole, user: finalUser }; - yield* waitForTempRole(ref, tempConn, dnsResolver); + yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans); return Option.some(tempConn); }); @@ -442,7 +556,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( * from `CommandSettings.workdir`'s cwd-walked value — e.g. `bootstrap`, whose own * `process.chdir` happens after that layer is built. `bootstrap` calls this directly with its * own local `workdir`/`projectRef`/`created.dbPassword`, without going through - * `DbConfigResolver`/`RemoteTarget` (both keyed off the ambient, potentially-stale + * `DbConfigResolver`/`ProjectRefResolver` (both keyed off the ambient, potentially-stale * `CommandSettings.workdir`). */ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(function* ( @@ -451,39 +565,33 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio projectHost: string, poolerHost: string, dnsResolver: "native" | "https", - passwordFlag: Option.Option, + explicitPassword: Option.Option, options: { + /** Whether the temp-role retry may clear listed bans; only a `--yes` approval sets it. */ + readonly removeNetworkBans: boolean; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** The pooler URL the link recorded for `ref`, used when the direct host is unreachable. */ readonly poolerUrl?: Option.Option; /** - * Requests the Management API pooler-config fetch on an IPv4-only network - * independent of `adHocProjectRef`'s credential/saved-URL semantics — see - * `DbConfigFlags.linkedProjectRef`'s doc comment. Set when the caller - * supplied an explicit ref (`--project-ref`/`--project-id` or - * `SUPABASE_PROJECT_ID`) rather than falling back to the link, so an unlinked or mismatched-tenant - * workdir still reaches the primary pooler config instead of dead-ending in - * the "run supabase link" IPv6 error. + * Requests the Management API pooler-config fetch on an IPv4-only network independent of + * `adHocProjectRef`'s saved-URL semantics, so a workdir with no usable saved pooler URL still + * reaches the primary pooler config instead of ending in the "run supabase link" IPv6 error. */ readonly fetchPoolerFromApi?: boolean; - } = {}, + /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ + readonly hasPasswordFlag?: boolean; + }, ) { const { adHocProjectRef = false, resolveVaultSecrets = true, fetchPoolerFromApi = false, + hasPasswordFlag = false, + removeNetworkBans, poolerUrl = Option.none(), } = options; - const debug = yield* DebugLogger; - // Read lazily (per invocation) rather than at layer build, so tests and - // env-substitution see the current value. For an ad-hoc `--project-id` ref, - // honor only an explicit `--password` flag and ignore the ambient - // `SUPABASE_DB_PASSWORD` (which belongs to the current workdir, not this ref), - // so we always mint a temporary login role instead of leaking it. - const dbPassword = adHocProjectRef - ? (Option.getOrUndefined(passwordFlag) ?? "") - : yield* resolveDbPassword(passwordFlag, workdir); + const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); const host = `db.${ref}.${projectHost}`; const base: PgConnInput = { host, @@ -515,21 +623,13 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio "connectTimeoutSeconds", ]), }; - if (direct.password.length > 0) { - yield* debug.debug("Using database password from env var..."); - return direct; - } + if (direct.password.length > 0) return direct; return yield* initLoginRole(ref, direct); } - // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-id` - // ref the command already holds a Management API token, so fall back to the API pooler config - // (and ignore the workdir's saved pooler URL) rather than failing with the IPv6 suggestion. An - // explicit `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the - // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still rejects a - // stale saved URL for a different ref), but independently requests the same API fetch via - // `fetchPoolerFromApi` so an unlinked or mismatched-tenant workdir doesn't dead-end in the - // IPv6 error. + // Direct host unreachable (IPv6-only network) → try the pooler. An ad-hoc ref skips the saved + // pooler URL, which belongs to the linked project; with `fetchPoolerFromApi`, either case falls + // back to the Management API's pooler config when an access token is available. const poolerConn = yield* resolvePoolerConn( ref, workdir, @@ -540,11 +640,15 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio adHocProjectRef || fetchPoolerFromApi, adHocProjectRef, resolveVaultSecrets, + removeNetworkBans, ); if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ message: "IPv6 is not supported on your current network", - suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + remedy: adHocProjectRef ? "pooler-url" : "link", + suggestion: adHocProjectRef + ? "Pass the project's connection pooler URL with --remote= to connect over IPv4." + : `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, }); } return poolerConn.value; @@ -563,11 +667,13 @@ export const dbConfigResolverLayer = Layer.effect( const debug = yield* DebugLogger; const output = yield* Output; const dbConn = yield* DbConnection; + const configValues = yield* CliConfigValues; + const commandTarget = yield* CommandTarget; // `resolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions that yield their // own `FileSystem`/`Path`/`DebugLogger`/`Output`/`DbConnection` (so bootstrap can call them // directly from its own ambient context). Calling them from here would otherwise leak those - // services into `resolve`/`resolvePoolerFallback`'s R (the interface promises `never`), so - // every call site below re-closes the gap by additionally providing this layer of + // services into `resolveConnection`/`resolvePoolerFallback`'s R (the interface promises + // `never`), so every call site below re-closes the gap by additionally providing this layer of // already-resolved values alongside `linkedDbResolverRuntimeLayer`. const localAmbientServices = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), @@ -575,6 +681,7 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(DebugLogger, debug), Layer.succeed(Output, output), Layer.succeed(DbConnection, dbConn), + Layer.succeed(CliConfigValues, configValues), ); // Profile context for the connect-failure suggestion. Snapshot it once and attach it to @@ -586,7 +693,7 @@ export const dbConfigResolverLayer = Layer.effect( }; // Capture the ambient services the Management API stack needs, so the - // lazily-built linked stack is fully self-provided and `resolve`'s R stays + // lazily-built linked stack is fully self-provided and `resolveConnection`'s R stays // `never` (handler tests can mock this resolver without wiring the whole // management runtime). None of these resolves an access token — only the // platform API layer does, and that is built only on the `--linked` branch. @@ -600,13 +707,14 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(DebugFlag, yield* DebugFlag), // `linkedDbResolverRuntimeLayer`'s platform-API factory provides a DoH fetch layer that // reads `DnsResolverFlag`; snapshot it so the lazily built linked stack stays fully - // self-provided (`resolve`'s R stays `never`). + // self-provided (`resolveConnection`'s R stays `never`). Layer.succeed(DnsResolverFlag, yield* DnsResolverFlag), Layer.succeed(RuntimeInfo, yield* RuntimeInfo), Layer.succeed(Analytics, yield* Analytics), Layer.succeed(TelemetryRuntime, yield* TelemetryRuntime), Layer.succeed(Tty, yield* Tty), Layer.succeed(Output, yield* Output), + Layer.succeed(CommandTarget, commandTarget), // The per-command identity stitcher, shared with the linked stack's lazy platform-API // factory + linked-project cache. Provided to this layer by each command runtime. Layer.succeed(IdentityStitch, yield* IdentityStitch), @@ -622,13 +730,13 @@ export const dbConfigResolverLayer = Layer.effect( // Compile-time guard: if `linkedDbResolverRuntimeLayer`'s requirements ever // grow a service not captured above, this assignment fails to type-check (the // lazy `Effect.provide` in the `--linked` branch would otherwise leak that - // service into `resolve`'s R and only surface as a runtime panic). Mirrors the + // service into `resolveConnection`'s R and only surface as a runtime panic). Mirrors the // `_serviceCoverageCheck` pattern in `management-api-runtime.layer.ts`. const _ambientCoverageCheck: Layer.Layer = ambientLayer; void _ambientCoverageCheck; - // `resolve`'s R is `never`, so capture StackApi at layer build. + // `resolveConnection`'s R is `never`, so capture StackApi at layer build. const stackDatabaseConn = stackLocalDatabaseConn.pipe( Effect.provideService(CommandSettings, cliSettings), Effect.provideService(StackApi, stackApi), @@ -636,149 +744,86 @@ export const dbConfigResolverLayer = Layer.effect( Effect.provideService(Path.Path, path), ); - const resolve = (flags: DbConfigFlags) => + // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or remote-Docker + // setups, defaulting to 127.0.0.1. Only env feeds the hostname; the config file stays unread. + const resolveLocalHost = Effect.gen(function* () { + const hostConfig = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + tolerateInvalid: true, + }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + return yield* getHostname(hostConfig).pipe( + Effect.provideService(RuntimeInfo, runtimeInfo), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.catchTag("CliConfigLoadError", (error) => + Effect.fail(new Errors.DbConfigLoadError({ message: error.message })), + ), + ); + }); + + const resolveDbUrl = (dbUrl: string, localHost: string, resolveVaultSecrets: boolean) => Effect.gen(function* () { - const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; - // Config is read per branch, not unconditionally up front: the linked branch resolves - // the ref first and reads the `[remotes.]`-merged config (below). A base read here - // would validate base config (db.major_version, deno_version, …) before the ref is - // known, failing a linked run that should succeed once the ref is known. Only - // `--db-url`/`--local` read base config, since neither merges a remote block. - // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or - // remote-Docker setups, defaulting to 127.0.0.1. - const localHost = yield* getHostname().pipe( - Effect.provideService(RuntimeInfo, runtimeInfo), + const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { + resolveVaultSecrets, + }); + // The project `.env*` files populate the environment that the libpq `PG*` fallbacks + // read. Layer the project env under the shell env (`loadProjectEnvValues` already excludes + // shell-set keys, so the shell still wins) and feed it to the parser. + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); + const conn = yield* parseConnectionString(dbUrl, yield* layeredParseEnv(projectEnv)).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), ); - - // --db-url (direct) takes precedence. - if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { - const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { - resolveVaultSecrets, + if (conn === undefined) { + return yield* new Errors.DbConfigParseUrlError({ + // Redact the password component before echoing the URL back + // (CWE-209): a malformed `--db-url` often still carries a secret. + message: `failed to parse connection string: ${redactConnectionString(dbUrl)}`, }); - // The project `.env*` files populate the environment that the libpq `PG*` fallbacks - // read. Layer the project env under the shell env (`loadProjectEnv` already excludes - // shell-set keys, so the shell still wins) and feed it to the parser. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); - const conn = yield* parseConnectionString( - flags.dbUrl.value, - yield* layeredParseEnv(projectEnv), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - if (conn === undefined) { - return yield* new Errors.DbConfigParseUrlError({ - // Redact the password component before echoing the URL back - // (CWE-209): a malformed `--db-url` often still carries a secret. - message: `failed to parse connection string: ${redactConnectionString(flags.dbUrl.value)}`, - }); - } - // A multi-host URL stays remote: local disables TLS for every fallback host as well. - const singleHost = conn.fallbacks === undefined; - const legacyLocal = - singleHost && - isLocalDatabase( - conn.host, - localHost, - conn.port, - tomlValues.port, - tomlValues.shadowPort, - ); - // The stack backend publishes its database on a runtime-assigned port rather than - // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. - const stackConn = - singleHost && - (yield* currentStackBackend).kind === "stack" && - mayBeStackDatabaseHost(conn.host, localHost) - ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) - : undefined; - const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; - const isLocal = legacyLocal || onStack; - // A local direct URL fills an empty password from the local database's own - // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` - // still authenticates. - const localPassword = onStack ? stackConn.password : tomlValues.password; - return { - conn: - isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, - isLocal, - }; - } - - // --linked. The lazy Management API runtime (remote target + lazy platform API - // factory) is provided here at runtime so it is only built on this branch — - // `--local`/`--db-url` never touch it. The factory resolves the access token only on - // first use (minting a temp role), so a `--linked --password` invocation stays - // auth-free. - if (flags.connType === "linked") { - const linked = yield* Effect.gen(function* () { - const remoteTarget = yield* RemoteTarget; - // Load-or-fail with no prompt: use `load` (not the soft `resolveOptional`, which - // swallows a read error to `None`) — an unlinked workdir fails with a not-linked - // error, a bad ref with the invalid-ref error, and an unreadable link surfaces the - // filesystem problem, for every caller of this resolver (`test db --linked`, dump, - // declarative). A caller that already resolved passes `flags.target`. - const target = - flags.target ?? (yield* remoteTarget.load(flags.linkedProjectRef ?? Option.none())); - const { ref } = target; - // The `[remotes.]`-merged config (e.g. an unsupported remote - // `db.major_version`/`edge_runtime.deno_version`) is validated as a pure config - // error before any network work. The base read in `resolve` above only validates - // remote `project_id`s, not the ref-merged block — so validate the merged config - // here, before the TCP probe / pooler / temp-role Management API calls, rather than - // letting those mask (or run side effects ahead of) the real config error. - yield* readDbToml(fs, path, cliSettings.workdir, ref, { - resolveVaultSecrets, - }); - const resolved = yield* resolveLinkedConn( - ref, - cliSettings.workdir, - cliSettings.projectHost, - cliSettings.poolerHost, - flags.dnsResolver, - flags.password ?? Option.none(), - { - adHocProjectRef: flags.adHocProjectRef ?? false, - resolveVaultSecrets, - poolerUrl: Option.fromNullishOr(target.poolerUrl), - // A ref from a flag or `SUPABASE_PROJECT_ID` independently unlocks the - // Management API pooler fetch, regardless of `adHocProjectRef`'s credential - // semantics. The target carries the saved pooler URL only when its ref is the - // linked one. - fetchPoolerFromApi: - target.source !== "link" || - // Covers an empty `--project-ref` value and `gen types`, which always passes the resolved ref. - Option.isSome(flags.linkedProjectRef ?? Option.none()), - }, - ); - // The linked-project telemetry cache (GET /v1/projects/{ref}) is not issued here: - // it's cached in a post-run hook, after the command's own API calls, so each linked - // command owns that GET in its post-run finalizer. Issuing it mid-resolve would - // reorder the request log ahead of the command's GETs. - return { conn: resolved, ref }; - }).pipe( - Effect.provide( - Layer.mergeAll( - linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), - localAmbientServices, - ), - ), - ); - // Surface the resolved ref so the caller can re-read config with a matching - // `[remotes.]` override applied (merged into the linked config). - return { conn: linked.conn, isLocal: false, ref: Option.some(linked.ref) }; } + // A multi-host URL stays remote: local disables TLS for every fallback host as well. + const singleHost = conn.fallbacks === undefined; + const legacyLocal = + singleHost && + isLocalDatabase(conn.host, localHost, conn.port, tomlValues.port, tomlValues.shadowPort); + // The stack backend publishes its database on a runtime-assigned port rather than + // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. + const stackConn = + singleHost && + (yield* currentStackBackend).kind === "stack" && + mayBeStackDatabaseHost(conn.host, localHost) + ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) + : undefined; + const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; + const isLocal = legacyLocal || onStack; + // A local direct URL fills an empty password from the local database's own + // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // still authenticates. + const localPassword = onStack ? stackConn.password : tomlValues.password; + return { + conn: isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, + isLocal, + exposedSchemas: tomlValues.exposedSchemas, + }; + }); - // --local (default). + const resolveLocalStack = (localHost: string, resolveVaultSecrets: boolean) => + Effect.gen(function* () { const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { resolveVaultSecrets, }); const backend = yield* currentStackBackend; if (backend.kind === "stack") { - return { conn: yield* stackDatabaseConn, isLocal: true }; + return { + conn: yield* stackDatabaseConn, + isLocal: true, + exposedSchemas: tomlValues.exposedSchemas, + }; } return { conn: { @@ -789,43 +834,132 @@ export const dbConfigResolverLayer = Layer.effect( database: "postgres", }, isLocal: true, + exposedSchemas: tomlValues.exposedSchemas, + }; + }); + + // Validates the `[remotes.]`-merged config before any network work, so a config error is + // never masked by the TCP probe, pooler or login-role calls. The linked-project cache GET is + // left to each command's post-run hook so it stays after the command's own requests. + const resolveLinkedConnection = ( + ref: string, + options: { + readonly dnsResolver: "native" | "https"; + readonly password: Option.Option; + readonly resolveVaultSecrets: boolean; + readonly adHocProjectRef: boolean; + readonly hasPasswordFlag: boolean; + readonly removeNetworkBans: boolean; + readonly poolerUrl: Option.Option; + }, + ) => + Effect.gen(function* () { + yield* readDbToml(fs, path, cliSettings.workdir, ref, { + resolveVaultSecrets: options.resolveVaultSecrets, + }); + return yield* resolveLinkedConn( + ref, + cliSettings.workdir, + cliSettings.projectHost, + cliSettings.poolerHost, + options.dnsResolver, + options.password, + { + adHocProjectRef: options.adHocProjectRef, + resolveVaultSecrets: options.resolveVaultSecrets, + fetchPoolerFromApi: true, + hasPasswordFlag: options.hasPasswordFlag, + removeNetworkBans: options.removeNetworkBans, + poolerUrl: options.poolerUrl, + }, + ); + }); + + // Built per linked call so `--local`/`--db-url` never touch it; the platform API factory + // resolves the access token only when a call needs it, and `--password` needs none. + const withLinkedRuntime = (effect: Effect.Effect) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), + localAmbientServices, + ), + ), + ); + + const resolveConnection = (input: DbConnectionInput) => + Effect.gen(function* () { + const { target } = input; + const linkedCheckout = + target.kind === "local" && + (yield* isCheckoutLinked(fs, path, cliSettings.workdir, cliSettings.projectId)); + if (target.kind !== "linked") { + yield* rejectTargetPassword(target.kind, input.password, linkedCheckout); + } + const resolveVaultSecrets = input.resolveVaultSecrets ?? true; + if (target.kind === "linked") { + const conn = yield* withLinkedRuntime( + resolveLinkedConnection(target.ref, { + dnsResolver: input.dnsResolver, + password: input.password, + resolveVaultSecrets, + adHocProjectRef: !target.matchesLinkedRef, + hasPasswordFlag: input.hasPasswordFlag ?? false, + removeNetworkBans: canRemoveNetworkBans(input), + poolerUrl: Option.fromNullishOr(target.poolerUrl), + }), + ); + return { conn, isLocal: false, ref: Option.some(target.ref), target }; + } + const localHost = yield* resolveLocalHost; + if (target.kind === "db-url") { + const resolved = yield* resolveDbUrl( + Redacted.value(target.url), + localHost, + resolveVaultSecrets, + ); + yield* commandTarget.announce(renderDbUrlTarget(resolved.conn.host, resolved.conn.port)); + return { ...resolved, target }; + } + const resolved = yield* resolveLocalStack(localHost, resolveVaultSecrets); + yield* commandTarget.announce(renderLocalTarget(resolved.conn.host, resolved.conn.port)); + return { + ...resolved, + conn: linkedCheckout + ? { ...resolved.conn, suggestionContext: { ...suggestionContext, linkedCheckout } } + : resolved.conn, + target, }; }); // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct // host is reachable from the CLI process but not from inside Docker), it resolves the - // project's IPv4 transaction pooler and retries once. This exposes that pooler resolution - // for the dump handler to invoke on demand. Returns `None` when the path is not - // pooler-eligible (`--linked` only) or no pooler URL is configured, so the caller keeps the - // original container error. - const resolvePoolerFallback = (flags: DbConfigFlags) => + // project's IPv4 transaction pooler and retries once. Returns `None` for a local or `--db-url` + // target or when no pooler URL is configured, so the caller keeps the original container error. + const resolvePoolerFallback = (input: DbConnectionInput) => Effect.gen(function* () { - if (flags.connType !== "linked") return Option.none(); + const { target } = input; + if (target.kind !== "linked") return Option.none(); return yield* Effect.gen(function* () { - const remoteTarget = yield* RemoteTarget; - const targetOpt = - flags.target === undefined - ? yield* remoteTarget.resolveOptional(flags.linkedProjectRef ?? Option.none()) - : Option.some(flags.target); - if (Option.isNone(targetOpt)) return Option.none(); - const { ref } = targetOpt.value; - if (!PROJECT_REF_PATTERN.test(ref)) return Option.none(); - const adHocProjectRef = flags.adHocProjectRef ?? false; - const password = adHocProjectRef - ? (Option.getOrUndefined(flags.password ?? Option.none()) ?? "") - : yield* resolveDbPassword(flags.password ?? Option.none(), cliSettings.workdir); + const password = yield* resolveLinkedPassword( + target.ref, + cliSettings.workdir, + input.password, + input.hasPasswordFlag ?? false, + ); // Container-fallback: fetch the primary pooler config from the Management API when // the link recorded no pooler URL. return yield* resolvePoolerConn( - ref, + target.ref, cliSettings.workdir, cliSettings.poolerHost, - flags.dnsResolver, + input.dnsResolver, password, - Option.fromNullishOr(targetOpt.value.poolerUrl), + Option.fromNullishOr(target.poolerUrl), true, - adHocProjectRef, - flags.resolveVaultSecrets ?? true, + !target.matchesLinkedRef, + input.resolveVaultSecrets ?? true, + canRemoveNetworkBans(input), ); }).pipe( Effect.provide( @@ -841,25 +975,27 @@ export const dbConfigResolverLayer = Layer.effect( // so each connecting command inherits the hint without per-call-site wiring. const withSuggestion = (conn: PgConnInput): PgConnInput => ({ ...conn, - suggestionContext, + suggestionContext: conn.suggestionContext ?? suggestionContext, }); return DbConfigResolver.of({ - resolve: (flags) => - resolve(flags).pipe( + resolveConnection: (input) => + resolveConnection(input).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.tap((r) => Effect.annotateCurrentSpan("db.is_local", r.isLocal)), Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) })), - Effect.withSpan("DbConfig.resolve", { - attributes: { "db.conn_type": flags.connType ?? "local" }, + Effect.withSpan("DbConfig.resolveConnection", { + attributes: { "db.conn_type": input.target.kind }, }), ), - resolvePoolerFallback: (flags) => - resolvePoolerFallback(flags).pipe( + resolvePoolerFallback: (input) => + resolvePoolerFallback(input).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.tap((pooler) => Effect.annotateCurrentSpan("db.pooler.found", Option.isSome(pooler)), ), Effect.map(Option.map(withSuggestion)), Effect.withSpan("DbConfig.resolvePoolerFallback", { - attributes: { "db.conn_type": flags.connType ?? "local" }, + attributes: { "db.conn_type": input.target.kind }, }), ), }); diff --git a/apps/cli/src/command-internal/db-config.parse.ts b/apps/cli/src/command-internal/db-config.parse.ts index cee27ae343..5185a62333 100644 --- a/apps/cli/src/command-internal/db-config.parse.ts +++ b/apps/cli/src/command-internal/db-config.parse.ts @@ -180,7 +180,7 @@ function unbracketIpv6(host: string): string { return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; } -/** Sentinel for a present-but-non-numeric `connect_timeout` (pgconn parse error). */ +/** Sentinel for a present-but-non-numeric `connect_timeout` (parse error). */ const CONNECT_TIMEOUT_INVALID = Symbol("connect-timeout-invalid"); /** diff --git a/apps/cli/src/command-internal/db-config.parse.unit.test.ts b/apps/cli/src/command-internal/db-config.parse.unit.test.ts index cee9bf681d..6ca10be83d 100644 --- a/apps/cli/src/command-internal/db-config.parse.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.parse.unit.test.ts @@ -53,18 +53,16 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect( - "defaults the user to the OS account when userinfo is omitted (libpq/pgconn parity)", - () => - Effect.gen(function* () { - expect(yield* parseConnectionString("postgresql://localhost/mydb")).toEqual({ - host: "localhost", - port: 5432, - user: osUser, - password: "", - database: "mydb", - }); - }), + it.effect("defaults the user to the OS account when userinfo is omitted (libpq parity)", () => + Effect.gen(function* () { + expect(yield* parseConnectionString("postgresql://localhost/mydb")).toEqual({ + host: "localhost", + port: 5432, + user: osUser, + password: "", + database: "mydb", + }); + }), ); it.effect("defaults user to the OS account and database to that user when both are omitted", () => @@ -103,7 +101,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { ), ); - it.effect("honors libpq query params (host/dbname) over the structural URL (pgconn parity)", () => + it.effect("honors libpq query params (host/dbname) over the structural URL", () => Effect.gen(function* () { expect( yield* parseConnectionString("postgresql:///postgres?host=/var/run/postgresql"), @@ -128,7 +126,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("strips the brackets from an IPv6 literal host (Go url.Hostname parity)", () => + it.effect("strips the brackets from an IPv6 literal host", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgresql://postgres:pw@[::1]:5432/postgres")).toEqual({ host: "::1", @@ -158,7 +156,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("collects non-structural query settings as runtimeParams (pgconn parity)", () => + it.effect("collects non-structural query settings as runtimeParams", () => Effect.gen(function* () { const parsed = yield* parseConnectionString( "postgres://u:pw@h/db?search_path=tenant&statement_timeout=5000&sslmode=require&options=reference%3Dabc", @@ -176,7 +174,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("merges PGAPPNAME into runtimeParams as application_name (pgconn env merge)", () => + it.effect("merges PGAPPNAME into runtimeParams as application_name (env merge)", () => Effect.gen(function* () { const env = (name: string): string | undefined => name === "PGAPPNAME" ? "myapp" : undefined; @@ -185,18 +183,16 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect( - "lets a connection-string application_name override PGAPPNAME (pgconn precedence)", - () => - Effect.gen(function* () { - const env = (name: string): string | undefined => - name === "PGAPPNAME" ? "from-env" : undefined; - const parsed = yield* parseConnectionString( - "postgres://u:pw@h/db?application_name=from-url", - env, - ); - expect(parsed?.runtimeParams?.application_name).toBe("from-url"); - }), + it.effect("lets a connection-string application_name override PGAPPNAME (precedence)", () => + Effect.gen(function* () { + const env = (name: string): string | undefined => + name === "PGAPPNAME" ? "from-env" : undefined; + const parsed = yield* parseConnectionString( + "postgres://u:pw@h/db?application_name=from-url", + env, + ); + expect(parsed?.runtimeParams?.application_name).toBe("from-url"); + }), ); it.effect("merges a pg_service.conf runtime setting (search_path) into runtimeParams", () => @@ -230,7 +226,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("resolves client certs from PGSSLCERT/PGSSLKEY env (pgconn precedence)", () => + it.effect("resolves client certs from PGSSLCERT/PGSSLKEY env (precedence)", () => Effect.gen(function* () { const env = (name: string): string | undefined => name === "PGSSLCERT" ? "/e/c.crt" : name === "PGSSLKEY" ? "/e/c.key" : undefined; @@ -240,7 +236,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("rejects a client cert with sslcert but no sslkey (pgconn both-or-neither)", () => + it.effect("rejects a client cert with sslcert but no sslkey (both or neither)", () => Effect.gen(function* () { expect( yield* parseConnectionString("postgres://u:pw@h/db?sslcert=/c/client.crt"), @@ -288,7 +284,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("rejects a non-numeric or empty ?port= query override (pgconn parsePort error)", () => + it.effect("rejects a non-numeric or empty ?port= query override (invalid port)", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgresql://host/db?port=abc")).toBeUndefined(); expect(yield* parseConnectionString("postgresql://db.example.com/app?port=")).toBeUndefined(); @@ -305,7 +301,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { ), ); - it.effect("rejects an invalid sslmode value (pgconn 'sslmode is invalid')", () => + it.effect("rejects an invalid sslmode value ('sslmode is invalid')", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://h/db?sslmode=verifyfull")).toBeUndefined(); expect(yield* parseConnectionString("host=h sslmode=bogus")).toBeUndefined(); @@ -325,7 +321,7 @@ layer(parseTestLayer)("parseConnectionString (URL form)", (it) => { }), ); - it.effect("fills sslmode from PGSSLMODE when the URL omits it (pgconn env default)", () => + it.effect("fills sslmode from PGSSLMODE when the URL omits it (env default)", () => withConfigEnv( { PGSSLMODE: "verify-full" }, Effect.gen(function* () { @@ -394,7 +390,7 @@ layer(parseTestLayer)("parseConnectionString (libpq keyword/value DSN)", (it) => }), ); - it.effect("prefers PGUSER over the OS account for the default user (pgconn env precedence)", () => + it.effect("prefers PGUSER over the OS account for the default user (env precedence)", () => withConfigEnv( { PGUSER: "pg_role" }, Effect.gen(function* () { @@ -411,7 +407,7 @@ layer(parseTestLayer)("parseConnectionString (libpq keyword/value DSN)", (it) => ), ); - it.effect("fills omitted DSN fields from PG* env vars (pgconn env defaults)", () => + it.effect("fills omitted DSN fields from PG* env vars (env defaults)", () => withConfigEnv( { PGHOST: "pg.env.com", PGPORT: "6543", PGPASSWORD: "env-secret", PGDATABASE: "envdb" }, Effect.gen(function* () { @@ -454,7 +450,7 @@ layer(parseTestLayer)("parseConnectionString (libpq keyword/value DSN)", (it) => ); }); -layer(parseTestLayer)("empty-password precedence (pgconn parity)", (it) => { +layer(parseTestLayer)("empty-password precedence", (it) => { // Points PGPASSFILE at a temp file we control and sets PGPASSWORD, to prove which one wins. const withPgpass = ( env: Readonly>, @@ -532,7 +528,7 @@ layer(parseTestLayer)("empty-password precedence (pgconn parity)", (it) => { ); }); -layer(parseTestLayer)("multi-host failover (pgconn parity)", (it) => { +layer(parseTestLayer)("multi-host failover", (it) => { it.effect("parses a comma-separated multi-host URL into primary + fallbacks", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://u:pw@h1:5432,h2:5433/db")).toEqual({ @@ -559,7 +555,7 @@ layer(parseTestLayer)("multi-host failover (pgconn parity)", (it) => { }), ); - it.effect("zips hosts to the (compacted) port list exactly as pgconn does", () => + it.effect("zips hosts to the (compacted) port list positionally", () => Effect.gen(function* () { // Empty ports are dropped before zipping, so a host that omits a port takes the next entry // in the compacted port list rather than inheriting the previous host's port; only a host @@ -596,7 +592,7 @@ layer(parseTestLayer)("multi-host failover (pgconn parity)", (it) => { }), ); - it.effect("rejects a multi-host URL with a non-numeric port (pgconn parsePort error)", () => + it.effect("rejects a multi-host URL with a non-numeric port (invalid port)", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://u:pw@h1:5432,h2:bad/db")).toBeUndefined(); }), @@ -623,7 +619,7 @@ layer(parseTestLayer)("multi-host failover (pgconn parity)", (it) => { ); }); -layer(parseTestLayer)("passfile= DSN setting (pgconn parity)", (it) => { +layer(parseTestLayer)("passfile= DSN setting", (it) => { // Points PGPASSFILE at one file and `passfile=` at a different one, to prove the // connection-string setting wins. const withPassfiles = (body: (customPath: string) => Effect.Effect) => @@ -731,6 +727,18 @@ layer(parseTestLayer)("injected env lookup (project dotenv parity)", (it) => { }), ); + it.effect("prefers a shell var over the project .env and falls back for unset names", () => + withConfigEnv( + { PGHOST: "shell-host" }, + Effect.gen(function* () { + const env = yield* layeredParseEnv({ PGHOST: "project-host", PGPORT: "6543" }); + expect(env("PGHOST")).toBe("shell-host"); + expect(env("PGPORT")).toBe("6543"); + expect(env("PGUSER")).toBeUndefined(); + }), + ), + ); + it.effect("keeps a set-but-empty shell var ahead of the project .env (layeredParseEnv)", () => withConfigEnv( { PGHOST: "" }, @@ -743,7 +751,7 @@ layer(parseTestLayer)("injected env lookup (project dotenv parity)", (it) => { ); }); -layer(parseTestLayer)("pgservice resolution (pgconn parity)", (it) => { +layer(parseTestLayer)("pgservice resolution", (it) => { // A `service=`/`PGSERVICE` resolves against the service file and merges its settings between // env and the explicit connection-string fields. `dbname` is remapped to `database`. An // unresolvable service is a hard parse error. @@ -847,7 +855,7 @@ layer(parseTestLayer)("pgservice resolution (pgconn parity)", (it) => { ); }); -layer(parseTestLayer)("keyword/value DSN backslash handling (pgconn parity)", (it) => { +layer(parseTestLayer)("keyword/value DSN backslash handling", (it) => { it.effect("preserves backslashes before ordinary chars (Windows cert paths)", () => Effect.gen(function* () { expect( @@ -878,20 +886,18 @@ layer(parseTestLayer)("keyword/value DSN backslash handling (pgconn parity)", (i }), ); - it.effect( - "rejects an unquoted value ending in a lone backslash (pgconn 'invalid backslash')", - () => - Effect.gen(function* () { - expect(yield* parseConnectionString("host=h user=u password=secret\\")).toBeUndefined(); - // A complete trailing `\\` escape pair is still accepted (→ single backslash). - expect( - (yield* parseConnectionString("host=h user=u dbname=d sslrootcert=C:\\\\"))?.sslrootcert, - ).toBe("C:\\"); - }), + it.effect("rejects an unquoted value ending in a lone backslash ('invalid backslash')", () => + Effect.gen(function* () { + expect(yield* parseConnectionString("host=h user=u password=secret\\")).toBeUndefined(); + // A complete trailing `\\` escape pair is still accepted (→ single backslash). + expect( + (yield* parseConnectionString("host=h user=u dbname=d sslrootcert=C:\\\\"))?.sslrootcert, + ).toBe("C:\\"); + }), ); }); -layer(parseTestLayer)("connect_timeout (pgconn parity)", (it) => { +layer(parseTestLayer)("connect_timeout", (it) => { it.effect("parses connect_timeout from a URL query into connectTimeoutSeconds", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://u:p@h/db?connect_timeout=15")).toMatchObject({ @@ -920,7 +926,7 @@ layer(parseTestLayer)("connect_timeout (pgconn parity)", (it) => { }), ); - it.effect("omits connectTimeoutSeconds when unset or zero (driver applies Go's default)", () => + it.effect("omits connectTimeoutSeconds when unset or zero (driver applies its default)", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://u:p@h/db")).not.toHaveProperty( "connectTimeoutSeconds", @@ -941,7 +947,7 @@ layer(parseTestLayer)("connect_timeout (pgconn parity)", (it) => { ); }); -layer(parseTestLayer)("empty URL query overrides (pgconn parity)", (it) => { +layer(parseTestLayer)("empty URL query overrides", (it) => { it.effect("an empty ?dbname= overrides the path with an empty database", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://u:p@host/production?dbname=")).toMatchObject({ @@ -966,7 +972,7 @@ layer(parseTestLayer)("empty URL query overrides (pgconn parity)", (it) => { ); }); -layer(parseTestLayer)("pgconn parse refinements", (it) => { +layer(parseTestLayer)("DSN parse refinements", (it) => { it.effect("accepts a comma-separated ?port= list for a multi-host URL", () => Effect.gen(function* () { expect(yield* parseConnectionString("postgres://h1,h2/db?port=5432,5433")).toMatchObject({ @@ -1023,7 +1029,7 @@ layer(parseTestLayer)("pgconn parse refinements", (it) => { ); }); -layer(parseTestLayer)("database= alias and empty service values (pgconn parity)", (it) => { +layer(parseTestLayer)("database= alias and empty service values", (it) => { const svcDir = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1047,21 +1053,19 @@ layer(parseTestLayer)("database= alias and empty service values (pgconn parity)" }), ); - it.effect( - "uses last-wins for dbname/database aliases in a DSN (pgconn remaps at parse time)", - () => - Effect.gen(function* () { - expect( - yield* parseConnectionString("host=h user=u dbname=template1 database=appdb"), - ).toMatchObject({ - database: "appdb", - }); - expect( - yield* parseConnectionString("host=h user=u database=appdb dbname=template1"), - ).toMatchObject({ - database: "template1", - }); - }), + it.effect("uses last-wins for dbname/database aliases in a DSN (remapped at parse time)", () => + Effect.gen(function* () { + expect( + yield* parseConnectionString("host=h user=u dbname=template1 database=appdb"), + ).toMatchObject({ + database: "appdb", + }); + expect( + yield* parseConnectionString("host=h user=u database=appdb dbname=template1"), + ).toMatchObject({ + database: "template1", + }); + }), ); it.effect("an empty service password= suppresses PGPASSWORD (falls through to .pgpass)", () => @@ -1112,7 +1116,7 @@ layer(parseTestLayer)("database= alias and empty service values (pgconn parity)" ); }); -layer(parseTestLayer)("more pgconn parse refinements", (it) => { +layer(parseTestLayer)("more DSN parse refinements", (it) => { it.effect( "honors a present-but-empty ?host= as a literal empty host (overrides structural)", () => @@ -1137,7 +1141,7 @@ layer(parseTestLayer)("more pgconn parse refinements", (it) => { }), ); - it.effect("does not dial hostaddr as the host (pgconn ignores hostaddr)", () => + it.effect("does not dial hostaddr as the host (hostaddr is ignored)", () => Effect.gen(function* () { const env = (name: string): string | undefined => (name === "PGHOST" ? "envhost" : undefined); const parsed = yield* parseConnectionString("hostaddr=10.0.0.5 user=u", env); diff --git a/apps/cli/src/command-internal/db-config.password.integration.test.ts b/apps/cli/src/command-internal/db-config.password.integration.test.ts new file mode 100644 index 0000000000..80cf4676a5 --- /dev/null +++ b/apps/cli/src/command-internal/db-config.password.integration.test.ts @@ -0,0 +1,225 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { flagInput } from "../../tests/helpers/config-values-layer.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { resolveLinkedPassword } from "./db-config.layer.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; + +const LINKED = "linkedprojectrefabcd"; +const TARGET = "targetprojectrefabcd"; + +const workdir = Effect.fnUntraced(function* ( + opts: { readonly linkedRef?: string; readonly dotenv?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "db-config-password-" }); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + if (opts.linkedRef !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), opts.linkedRef); + } + if (opts.dotenv !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), opts.dotenv); + } + return dir; +}); + +const withWorkdir = ( + opts: { readonly linkedRef?: string; readonly dotenv?: string }, + use: (dir: string) => Effect.Effect, +) => Effect.flatMap(workdir(opts), use).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const run = ( + effect: Effect.Effect, + opts: { + readonly env?: Record; + readonly flagPassword?: string; + } = {}, +) => { + const out = mockOutput(); + const debugLines: Array = []; + const layer = Layer.mergeAll( + out.layer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), + ), + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs( + opts.flagPassword === undefined + ? [] + : [flagInput("linkedDb.password", "password", opts.flagPassword)], + ), + ), + ), + ), + ), + Layer.succeed(DebugLogger, { + debug: (message) => Effect.sync(() => void debugLines.push(message)), + http: () => Effect.void, + }), + ); + return effect.pipe( + Effect.provide(layer), + Effect.map((result) => ({ + result, + stderr: out.stderrText, + warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), + debugLines, + })), + ); +}; + +const ENV = { SUPABASE_DB_PASSWORD: "env-password" }; + +describe("resolveLinkedPassword", () => { + it.effect("uses the env password when the target is the linked project", () => + withWorkdir({ linkedRef: TARGET }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ result, warnings, debugLines }) => + Effect.sync(() => { + expect(result).toBe("env-password"); + expect(warnings).toEqual([]); + expect(debugLines).toEqual([ + "Using database password from SUPABASE_DB_PASSWORD (environment)...", + ]); + }), + ), + ), + ), + ); + + it.effect("uses the env password when the workdir is not linked", () => + withWorkdir({}, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ result, warnings }) => + Effect.sync(() => { + expect(result).toBe("env-password"); + expect(warnings).toEqual([]); + }), + ), + ), + ), + ); + + it.effect( + "ignores the env password for a project other than the linked one and says so once", + () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run( + Effect.gen(function* () { + const first = yield* resolveLinkedPassword(TARGET, dir, Option.none(), true); + const second = yield* resolveLinkedPassword(TARGET, dir, Option.none(), true); + return [first, second]; + }), + { env: ENV }, + ).pipe( + Effect.tap(({ result, warnings, debugLines }) => + Effect.sync(() => { + expect(result).toEqual(["", ""]); + expect(warnings).toEqual([ + `Not sending SUPABASE_DB_PASSWORD to ${TARGET}: this directory is linked to ${LINKED}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for ${TARGET}.`, + ]); + expect(debugLines).toEqual([ + "No database password found; using a temporary login role...", + "No database password found; using a temporary login role...", + ]); + }), + ), + ), + ), + ); + + it.effect("omits the --password sentence for commands without a --password binding", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ warnings }) => + Effect.sync(() => { + expect(warnings).toEqual([ + `Not sending SUPABASE_DB_PASSWORD to ${TARGET}: this directory is linked to ${LINKED}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN).`, + ]); + }), + ), + ), + ), + ); + + it.effect("ignores a project .env password for a project other than the linked one", () => + withWorkdir({ linkedRef: LINKED, dotenv: "SUPABASE_DB_PASSWORD=dotenv-password\n" }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none())).pipe( + Effect.tap(({ result, warnings }) => + Effect.sync(() => { + expect(result).toBe(""); + expect(warnings).toHaveLength(1); + }), + ), + ), + ), + ); + + it.effect("uses the project .env password for the linked project", () => + withWorkdir({ linkedRef: TARGET, dotenv: "SUPABASE_DB_PASSWORD=dotenv-password\n" }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none())).pipe( + Effect.tap(({ result, debugLines }) => + Effect.sync(() => { + expect(result).toBe("dotenv-password"); + expect(debugLines[0]).toContain("SUPABASE_DB_PASSWORD ("); + expect(debugLines[0]).toContain(".env"); + }), + ), + ), + ), + ); + + it.effect("uses the --password flag for a foreign project without a notice", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { + env: ENV, + flagPassword: "flag-password", + }).pipe( + Effect.tap(({ result, stderr, debugLines }) => + Effect.sync(() => { + expect(result).toBe("flag-password"); + expect(debugLines).toEqual(["Using database password from --password..."]); + expect(stderr).not.toContain("SUPABASE_DB_PASSWORD"); + }), + ), + ), + ), + ); + + it.effect("prefers --password over the env password for the linked project", () => + withWorkdir({ linkedRef: TARGET }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { + env: ENV, + flagPassword: "flag-password", + }).pipe(Effect.tap(({ result }) => Effect.sync(() => expect(result).toBe("flag-password")))), + ), + ); + + it.effect("returns a caller-supplied password without loading config", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.some("created-password")), { + env: ENV, + }).pipe( + Effect.tap(({ result, stderr }) => + Effect.sync(() => { + expect(result).toBe("created-password"); + expect(stderr).toBe(""); + }), + ), + ), + ), + ); +}); diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index d4377428d7..d2d91992fc 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -8,7 +8,7 @@ import type { ProjectRefNotLinkedError, } from "../config/project-ref.errors.ts"; import type { ProfileLoadError } from "./profile-load.ts"; -import type { LinkedProjectRefReadError } from "./project-link.ts"; +import type { LinkedProjectRefReadError } from "../shared/config/project-link.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; import type { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import type { @@ -24,8 +24,14 @@ import type { DbConfigPoolerLoginError, DbConfigUnbanNetworkError, DbConfigUnbanStatusError, + LinkedDbNetworkBannedError, } from "./db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "./db-config.types.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../config/cli-config.errors.ts"; +import type { TargetPasswordConflictError } from "./target/target.errors.ts"; /** Every error the resolver can raise across the direct / local / linked paths. */ export type DbConfigError = @@ -33,6 +39,8 @@ export type DbConfigError = | DbConfigParseUrlError | DbConfigLibpqEnvError | DbConfigLoadError + | CliConfigValueError + | CliConfigFlagConflictError | LocalDbRunningError | ProjectRefNotLinkedError | InvalidProjectRefError @@ -49,35 +57,39 @@ export type DbConfigError = | HttpBody.HttpBodyError | DbConfigIpv6Error | DbConfigConnectTempRoleError + | LinkedDbNetworkBannedError + | TargetPasswordConflictError | DbConfigPoolerLoginError | DbConnectError // The `--linked` path resolves the access token lazily via - // `CommandPlatformApiFactory.make` (only when minting a temp login role), so the + // `CommandPlatformApiFactory.make` (when minting a temp login role), so the // auth-required / invalid-token / api-config errors surface from the resolver effect — not a - // layer-build channel. `--linked --password` skips `make` entirely and never raises these. + // layer-build channel. `--linked --password` never raises these: a failed `make` only skips + // the pooler-config fallback. | CommandPlatformApiFactoryError // The lazy linked runtime rebuilds `commandSettingsLayer`, whose strict // profile resolution can fail inside the resolver effect the same way. | ProfileLoadError; -// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never -// resolve an access token) and provides every one of its own requirements from the resolver's -// captured context, so `resolve`'s R stays `never`. Access-token resolution is deferred to -// first API use, so its auth-required error surfaces through the resolver effect (folded into -// `DbConfigError`) rather than a layer-build error channel. +// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never need an +// access token) from the resolver's captured context, so `resolve`'s R stays `never` and the +// auth-required error surfaces through the resolver effect, not a layer-build error channel. interface DbConfigResolverShape { - readonly resolve: (flags: DbConfigFlags) => Effect.Effect; + /** Connects to a resolved target (local, `--db-url` or the linked temp role) and announces it. */ + readonly resolveConnection: ( + input: DbConnectionInput, + ) => Effect.Effect; /** * Resolves the IPv4 transaction pooler connection for a linked dump's container-level - * fallback. Returns `None` when the path is not pooler-eligible (`--linked` only) or no - * pooler URL is configured, so the caller keeps the original error. + * fallback. Returns `None` when the target is not the linked project or no pooler URL is + * configured, so the caller keeps the original error. */ readonly resolvePoolerFallback: ( - flags: DbConfigFlags, + input: DbConnectionInput, ) => Effect.Effect, DbConfigError>; } -/** Resolves a Postgres connection from the `--db-url` / `--local` / `--linked` flags. */ +/** Resolves a Postgres connection for a resolved `local`, `db-url` or `linked` target. */ export class DbConfigResolver extends Context.Service()( "supabase/cli/DbConfigResolver", ) {} diff --git a/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts new file mode 100644 index 0000000000..d75c0cd9ae --- /dev/null +++ b/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts @@ -0,0 +1,241 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Path, Result } from "effect"; + +import { ConfigEnvPins } from "../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../tests/helpers/config-goldens.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { checkDbToml } from "./db-config.toml-read.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const OTHER_REF = "zyxwvutsrqponmlkjihg"; +const GOLDEN_DIR = "./testdata/config-precedence/db-toml"; + +const BASE_CONFIG = `project_id = "golden-base" + +[api] +schemas = ["public", "base_api"] + +[db] +port = 55001 +shadow_port = 55002 + +[db.migrations] +enabled = true +schema_paths = ["./schemas/*.sql"] + +[db.seed] +enabled = true +sql_paths = ["./seeds/*.sql"] + +[auth] +enabled = true + +[storage] +enabled = true + +[realtime] +enabled = true + +[experimental.pgdelta] +enabled = false +declarative_schema_path = "base-declarative" +`; + +const remoteBlock = (projectId: string) => ` +[remotes.prod] +project_id = "${projectId}" + +[remotes.prod.db.migrations] +enabled = false + +[remotes.prod.auth] +enabled = false + +[remotes.prod.experimental.pgdelta] +declarative_schema_path = "remote-declarative" +`; + +const SHELL_OVERRIDES = { + SUPABASE_DB_MIGRATIONS_ENABLED: "false", + SUPABASE_DB_PORT: "56001", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_DB_SEED_ENABLED: "false", +}; + +const DOTENV_OVERRIDES = [ + "SUPABASE_DB_MIGRATIONS_ENABLED=false", + "SUPABASE_DB_PORT=57001", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_DB_SEED_ENABLED=false", + "", +].join("\n"); + +const CONFLICTING_SHELL = { + SUPABASE_DB_MIGRATIONS_ENABLED: "true", + SUPABASE_AUTH_ENABLED: "true", + SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH: "shell-declarative", + SUPABASE_DB_SEED_ENABLED: "true", + SUPABASE_DB_PORT: "56001", + SUPABASE_API_SCHEMAS: "public,shell_api", +}; + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; + readonly ref?: string; + readonly expectedProjectId?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "a-base-only", + name: "base config with no env and no remote resolves its own values", + config: BASE_CONFIG, + ref: TARGET_REF, + }, + { + golden: "b-matched-remote", + name: "matched remote overrides migrations, auth and pgdelta and defaults seeding off", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + ref: TARGET_REF, + }, + { + golden: "b2-remote-block-without-ref", + name: "a remote block is ignored when no target ref is supplied", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + }, + { + golden: "c-shell-env", + name: "shell env overrides base config for migrations, port, api schemas and seed", + config: BASE_CONFIG, + shellEnv: SHELL_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "d-project-dotenv", + name: "supabase/.env overrides base config for migrations, port, api schemas and seed", + config: BASE_CONFIG, + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "d2-shell-beats-dotenv", + name: "shell env beats supabase/.env for the same key", + config: BASE_CONFIG, + shellEnv: SHELL_OVERRIDES, + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "e-remote-beats-shell", + name: "shell env beats a matched remote for db.migrations.enabled, auth.enabled, pgdelta path and seed", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + shellEnv: CONFLICTING_SHELL, + ref: TARGET_REF, + }, + { + golden: "e2-remote-beats-dotenv", + name: "supabase/.env beats a matched remote for db.migrations.enabled and seed", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "f-remote-matched-by-env-project-id", + name: "SUPABASE_REMOTES_PROD_PROJECT_ID selects the remote block for the target ref", + config: BASE_CONFIG + remoteBlock(OTHER_REF), + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + ref: TARGET_REF, + }, + { + golden: "f2-remote-env-match-project-id-is-block-literal", + name: "remote matched via SUPABASE_REMOTES_PROD_PROJECT_ID yields projectId equal to the block's TOML literal, not the target ref", + config: BASE_CONFIG + remoteBlock(OTHER_REF), + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + ref: TARGET_REF, + expectedProjectId: OTHER_REF, + }, + { + golden: "g-captcha-secret-from-env-only", + name: "captcha enabled in TOML passes validation with the secret only in SUPABASE_AUTH_CAPTCHA_SECRET", + config: `${BASE_CONFIG} +[auth.captcha] +enabled = true +provider = "hcaptcha" +`, + shellEnv: { SUPABASE_AUTH_CAPTCHA_SECRET: "fake-captcha-secret" }, + ref: TARGET_REF, + }, + { + golden: "h-storage-realtime-env-disable", + name: "SUPABASE_STORAGE_ENABLED and SUPABASE_REALTIME_ENABLED disable the baseline storage and realtime flags", + config: BASE_CONFIG, + shellEnv: { SUPABASE_STORAGE_ENABLED: "false", SUPABASE_REALTIME_ENABLED: "false" }, + ref: TARGET_REF, + }, + { + golden: "i-project-id-env-with-matched-remote", + name: "SUPABASE_PROJECT_ID beats a matched remote project_id", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + ref: TARGET_REF, + }, + { + golden: "i2-project-id-env-without-remote", + name: "SUPABASE_PROJECT_ID overrides the base project_id when no remote matches", + config: BASE_CONFIG, + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + ref: TARGET_REF, + }, +]; + +const tempRoot = useTempWorkdir("db-toml-golden-"); + +describe("db toml reader precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.effect(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const shell = fixture.shellEnv ?? {}; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(shell); + + const outcome = yield* checkDbToml(fs, path, workdir, fixture.ref).pipe( + Effect.provide(cliConfigValuesTestLayer), + Effect.provideService(ConfigEnvPins, shell), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(shell, { preserveEmptyStrings: true }), + ), + Effect.result, + ); + + const snapshot = Result.isSuccess(outcome) + ? { ok: outcome.success } + : { error: { tag: outcome.failure._tag, message: outcome.failure.message } }; + if (fixture.expectedProjectId !== undefined) { + ctx + .expect(Result.isSuccess(outcome) ? outcome.success.projectId : undefined) + .toBe(fixture.expectedProjectId); + } + const goldenText = yield* goldenJson(snapshot, { [workdir]: "" }); + yield* Effect.promise(() => + ctx.expect(goldenText).toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index 5134fbd1b2..56f30ef128 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -1,18 +1,21 @@ -import { normalizeDeprecatedOrioleDBVersion } from "@supabase/config/internal"; -import { Config, Effect, Match, type FileSystem, Option, type Path } from "effect"; -import * as SmolToml from "smol-toml"; +import { Effect, FileSystem, Option, Path } from "effect"; +import type { CliConfigFlagDeclaration } from "../config/cli-config-flags.ts"; +import type { CliConfigKey } from "../config/cli-config-key.ts"; +import { envReferenceNames } from "../config/cli-config-document.ts"; +import { CliConfigKeys, cliConfigFamilyKey, cliConfigRegistry } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { loadCliProjectEnvFiles } from "../shared/config/cli-config-env.ts"; import { - PROJECT_REF_PATTERN, type AnalyticsInput, type AuthInput, type CaptchaInput, type ConfigValidationInput, - type DbInput, + ConfigValidateError, emailContentPathReadErrorMessage, + fsErrorCause, type ExperimentalInput, type HookInput, type MfaFactorInput, - parseGoBool, type PasskeyInput, resolveEmailTemplateContentPath, resolveSigningKeysPath, @@ -20,20 +23,16 @@ import { signingKeysReadErrorMessage, type SmtpInput, type ThirdPartyInput, + validateAuthExternalProviders, + validateAuthSmsProviders, validateResolvedConfig, } from "./config-validate.ts"; -import { DbConfigLoadError } from "./db-config.errors.ts"; +import { DbConfigLoadError, isPassthroughLoadFailure } from "./db-config.errors.ts"; import { recordOrioleDbTelemetry, selectsOrioleDb } from "./db-image.ts"; -import { parseDotEnv } from "./dotenv.ts"; -import { - deprecatedOrioleDBVersionEnvWarning, - readOrioleDBVersionEnvOverride, - strToArr, -} from "./local-config-values.ts"; +import { resolveAuthExternalProviders } from "./local-config-values.ts"; import { ramInBytes } from "./size-units.ts"; -import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; -import { Output } from "../shared/output/output.service.ts"; -import { configParseCauseMessage } from "../shared/output/normalize-error.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; +import { decryptSecret, isEncryptedSecret } from "../shared/config/vault-decrypt.ts"; /** Resolves a config `env(VAR)` reference: shell env first, then project `.env`. */ type EnvLookup = (name: string) => string | undefined; @@ -45,27 +44,22 @@ type EnvLookup = (name: string) => string | undefined; */ export interface DbTomlValues { readonly projectEnv: Readonly>; - /** - * Resolves a `SUPABASE_*` env var: shell env (non-empty) wins, then the - * loaded project `.env*` files (non-empty), else `undefined`. Handlers must - * call this rather than reading `process.env` directly. - */ - readonly envLookup: (name: EnvOverrideName) => string | undefined; readonly apiSchemas: ReadonlyArray; + /** Schemas the Data API serves; empty when `[api].enabled` is false. */ + readonly exposedSchemas: ReadonlyArray; /** `[db] port`, default 54322 (`packages/config/src/db.ts`). */ readonly port: number; /** `[db] shadow_port`, default 54320. */ readonly shadowPort: number; /** `[db] password`, runtime default `"postgres"` (not in the config schema). */ readonly password: string; - /** top-level `project_id`, used to name the local docker network. */ - readonly projectId: Option.Option; + /** The resolved, sanitized `project_id` (flag, env, config, then the workdir name). */ + readonly projectId: string; /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** - * `[db] orioledb_version` (env-expanded); the deprecated `[experimental] orioledb_version` is - * already promoted into this path by `normalizeDeprecatedOrioleDBVersion`. Set on a 15/17 - * project to rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. + * `[db] orioledb_version` (env-expanded). Set on a 15/17 project to rewrite the Postgres image + * to the OrioleDB tag; `None` for a vanilla project. */ readonly orioledbVersion: Option.Option; /** @@ -112,13 +106,6 @@ export interface DbTomlValues { * (`Loading config override: [remotes.]` line), else `undefined`. */ readonly appliedRemote: string | undefined; - /** - * The config keys the matched remote block contributed at override tier — - * see {@link RemoteOverride.remoteOverrideKeys}. Exposed so a separate config - * read for the same linked ref can apply the identical remote-over-env - * precedence without re-deriving this set. Empty when no remote matched. - */ - readonly remoteOverrideKeys: ReadonlySet; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -159,7 +146,7 @@ interface BaselineTomlConfig { /** The `[experimental.pgdelta]` subtree. */ export interface PgDeltaTomlConfig { - /** `[experimental.pgdelta] enabled`, default false. */ + /** `[experimental.pgdelta] enabled`, default true. */ readonly enabled: boolean; /** * `[experimental.pgdelta] declarative_schema_path`, resolved to a @@ -171,14 +158,6 @@ export interface PgDeltaTomlConfig { readonly formatOptions: Option.Option; } -const DEFAULT_PORT = 54322; -const DEFAULT_SHADOW_PORT = 54320; -const DEFAULT_MAJOR_VERSION = 17; -const DEFAULT_PASSWORD = "postgres"; -const DEFAULT_API_SCHEMAS = ["public", "graphql_public"] as const; -/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ -const DEFAULT_DENO_VERSION = 2; - /** Default declarative schema dir. */ const DEFAULT_DECLARATIVE_DIR_SEGMENTS = ["supabase", "schemas"] as const; @@ -190,453 +169,14 @@ function asRecord(value: unknown): RawDoc | undefined { : undefined; } -/** Recursively merge `override` over `base` (nested tables merge, scalars/arrays - * replace), per-key. */ -function deepMergeDoc(base: RawDoc, override: RawDoc): RawDoc { - const out: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - const baseValue = out[key]; - const baseRecord = asRecord(baseValue); - const overrideRecord = asRecord(value); - out[key] = - baseRecord !== undefined && overrideRecord !== undefined - ? deepMergeDoc(baseRecord, overrideRecord) - : value; - } - return out; -} - -/** - * Merge the `[remotes.]` block whose `project_id` equals `ref` over the base - * config. The block key name is only used for diagnostics; the match is on - * `project_id`. - */ -interface RemoteOverride { - readonly doc: RawDoc | undefined; - /** - * The name of the matched `[remotes.]` block whose `project_id` equals the - * resolved ref, or `undefined` when no block matched. Callers echo the - * `Loading config override: [remotes.]` stderr line from this. - */ - readonly appliedRemote?: string; - /** - * The config keys the matched remote block contributed at override tier; - * each must outrank the matching `SUPABASE_*` env override. Holds every key - * in {@link ENV_OVERRIDABLE_KEYS} the block supplies, plus `db.seed.enabled` - * (always forced). - */ - readonly remoteOverrideKeys: ReadonlySet; -} - -const remoteProjectIdEnvName = (name: string): string => - `SUPABASE_REMOTES_${name.toUpperCase()}_PROJECT_ID`; - -/** - * The `project_id` of a `[remotes.]` block for matching/duplicate - * detection: `SUPABASE_REMOTES__PROJECT_ID` wins when non-empty, else - * the raw (unexpanded) TOML literal. Validation instead uses the expanded - * value — see {@link resolveValidatedRemoteProjectId}. - */ -function resolveRemoteProjectId( - name: string, - block: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const fromEnv = lookup(remoteProjectIdEnvName(name)); - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const literal = block?.["project_id"]; - return typeof literal === "string" ? literal : undefined; -} - -/** - * The `project_id` of a `[remotes.]` block for validation: the same - * override precedence as {@link resolveRemoteProjectId}, but with `env(...)` - * expanded (an unset `env(...)` stays literal and fails the ref pattern). - */ -function resolveValidatedRemoteProjectId( - name: string, - block: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const fromEnv = lookup(remoteProjectIdEnvName(name)); - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const literal = block?.["project_id"]; - return typeof literal === "string" ? expandEnv(literal, lookup) : undefined; -} - -/** - * Every dotted config key this reader resolves with a `SUPABASE_*` env override. - * When a matched `[remotes.*]` block supplies any of these, the block value - * must beat the matching env override. - */ -const ENV_OVERRIDABLE_KEYS = [ - "project_id", - "api.schemas", - "db.port", - "db.shadow_port", - "db.major_version", - "db.migrations.enabled", - "db.migrations.schema_paths", - "db.seed.enabled", - "db.seed.sql_paths", - "auth.enabled", - "api.enabled", - "edge_runtime.deno_version", - "experimental.webhooks.enabled", - "experimental.pgdelta.enabled", - "experimental.pgdelta.declarative_schema_path", - "experimental.pgdelta.format_options", - "api.auto_expose_new_tables", - "analytics.enabled", - "analytics.backend", - "analytics.gcp_project_id", - "analytics.gcp_project_number", - "analytics.gcp_jwt_path", - "db.orioledb_version", - "experimental.s3_host", - "experimental.s3_region", - "experimental.s3_access_key", - "experimental.s3_secret_key", - "realtime.enabled", - "realtime.ip_version", - "realtime.max_header_length", - "storage.enabled", - "storage.file_size_limit", - "db.health_timeout", - "db.settings.effective_cache_size", - "db.settings.logical_decoding_work_mem", - "db.settings.maintenance_work_mem", - "db.settings.max_connections", - "db.settings.max_locks_per_transaction", - "db.settings.max_parallel_maintenance_workers", - "db.settings.max_parallel_workers", - "db.settings.max_parallel_workers_per_gather", - "db.settings.max_replication_slots", - "db.settings.max_slot_wal_keep_size", - "db.settings.max_standby_archive_delay", - "db.settings.max_standby_streaming_delay", - "db.settings.max_wal_size", - "db.settings.max_wal_senders", - "db.settings.max_worker_processes", - "db.settings.session_replication_role", - "db.settings.shared_buffers", - "db.settings.statement_timeout", - "db.settings.track_activity_query_size", - "db.settings.track_commit_timestamp", - "db.settings.wal_keep_size", - "db.settings.wal_sender_timeout", - "db.settings.work_mem", - "db.network_restrictions.enabled", - "db.root_key", - "api.port", - "api.tls.enabled", - "api.tls.cert_path", - "api.tls.key_path", - "api.external_url", - "auth.jwt_secret", - "auth.jwt_expiry", - "auth.site_url", - "auth.anon_key", - "auth.service_role_key", - "auth.signing_keys_path", - "auth.external_url", - "auth.third_party.firebase.enabled", - "auth.third_party.firebase.project_id", - "auth.third_party.auth0.enabled", - "auth.third_party.auth0.tenant", - "auth.third_party.auth0.tenant_region", - "auth.third_party.aws_cognito.enabled", - "auth.third_party.aws_cognito.user_pool_id", - "auth.third_party.aws_cognito.user_pool_region", - "auth.third_party.clerk.enabled", - "auth.third_party.clerk.domain", - "auth.third_party.workos.enabled", - "auth.third_party.workos.issuer_url", - "auth.jwt_issuer", - "auth.additional_redirect_urls", - "studio.enabled", - "studio.port", - "local_smtp.enabled", - "local_smtp.port", - "auth.enable_signup", - "auth.enable_anonymous_sign_ins", - "auth.enable_refresh_token_rotation", - "auth.refresh_token_reuse_interval", - "auth.enable_manual_linking", - "auth.minimum_password_length", - "auth.password_requirements", - "auth.passkey.enabled", - "auth.webauthn.rp_id", - "auth.webauthn.rp_origins", - "auth.hook.mfa_verification_attempt.enabled", - "auth.hook.mfa_verification_attempt.uri", - "auth.hook.mfa_verification_attempt.secrets", - "auth.hook.password_verification_attempt.enabled", - "auth.hook.password_verification_attempt.uri", - "auth.hook.password_verification_attempt.secrets", - "auth.hook.custom_access_token.enabled", - "auth.hook.custom_access_token.uri", - "auth.hook.custom_access_token.secrets", - "auth.hook.send_sms.enabled", - "auth.hook.send_sms.uri", - "auth.hook.send_sms.secrets", - "auth.hook.send_email.enabled", - "auth.hook.send_email.uri", - "auth.hook.send_email.secrets", - "auth.hook.before_user_created.enabled", - "auth.hook.before_user_created.uri", - "auth.hook.before_user_created.secrets", - "auth.mfa.totp.enroll_enabled", - "auth.mfa.totp.verify_enabled", - "auth.mfa.phone.enroll_enabled", - "auth.mfa.phone.verify_enabled", - "auth.mfa.phone.otp_length", - "auth.mfa.web_authn.enroll_enabled", - "auth.mfa.web_authn.verify_enabled", - "auth.mfa.max_enrolled_factors", - "auth.mfa.phone.template", - "auth.mfa.phone.max_frequency", - "auth.captcha.enabled", - "auth.captcha.provider", - "auth.captcha.secret", - "auth.email.smtp.enabled", - "auth.email.smtp.port", - "auth.email.smtp.pass", - "auth.email.smtp.host", - "auth.email.smtp.user", - "auth.email.smtp.admin_email", - "auth.email.smtp.sender_name", - "auth.email.enable_signup", - "auth.email.double_confirm_changes", - "auth.email.enable_confirmations", - "auth.email.secure_password_change", - "auth.email.otp_length", - "auth.email.otp_expiry", - "auth.email.max_frequency", - "auth.sms.enable_signup", - "auth.sms.enable_confirmations", - "auth.sms.twilio.enabled", - "auth.sms.twilio.auth_token", - "auth.sms.twilio_verify.enabled", - "auth.sms.twilio_verify.auth_token", - "auth.sms.messagebird.enabled", - "auth.sms.messagebird.access_key", - "auth.sms.textlocal.enabled", - "auth.sms.textlocal.api_key", - "auth.sms.vonage.enabled", - "auth.sms.vonage.api_secret", - "auth.sms.twilio.account_sid", - "auth.sms.twilio.message_service_sid", - "auth.sms.twilio_verify.account_sid", - "auth.sms.twilio_verify.message_service_sid", - "auth.sms.messagebird.originator", - "auth.sms.textlocal.sender", - "auth.sms.vonage.from", - "auth.sms.vonage.api_key", - "auth.sms.template", - "auth.sms.max_frequency", - "auth.publishable_key", - "auth.secret_key", - "studio.openai_api_key", - "studio.api_url", -] as const; - -/** - * `auth.external.` is a map keyed by arbitrary provider name, so these - * per-provider leaves can't be enumerated in {@link ENV_OVERRIDABLE_KEYS} — - * {@link applyRemoteOverride} tracks them dynamically instead. - */ -const AUTH_EXTERNAL_PROVIDER_FIELDS = [ - "enabled", - "client_id", - "secret", - "url", - "redirect_uri", - "skip_nonce_check", - "email_optional", -] as const; - -/** - * `auth.email.template.`/`auth.email.notification.` are the same - * arbitrarily-keyed shape as `auth.external.` above, tracked dynamically - * in {@link applyRemoteOverride} rather than enumerated here. - */ -const AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as const; - -/** {@link AUTH_EMAIL_TEMPLATE_FIELDS}'s notification-section sibling — same fields, plus `enabled`. */ -const AUTH_EMAIL_NOTIFICATION_FIELDS = ["enabled", "subject", "content_path", "content"] as const; - -/** - * Every literal member of {@link ENV_OVERRIDABLE_KEYS}, plus the dotted-key - * patterns for the dynamically-keyed families {@link applyRemoteOverride} - * tracks separately. Every `remoteWins(...)` call site is typed against this - * union, so a typo'd dotted key is a compile error instead of a - * silently-always-false gate. - */ -export type RemoteOverridableKey = - | (typeof ENV_OVERRIDABLE_KEYS)[number] - | `auth.external.${string}.${(typeof AUTH_EXTERNAL_PROVIDER_FIELDS)[number]}` - | `auth.email.template.${string}.${(typeof AUTH_EMAIL_TEMPLATE_FIELDS)[number]}` - | `auth.email.notification.${string}.${(typeof AUTH_EMAIL_NOTIFICATION_FIELDS)[number]}`; - -/** - * Hoists the `remoteOverrideKeys.has(key)` closure duplicated across several - * resolvers into one helper, typed against {@link RemoteOverridableKey} so a - * typo'd key is a compile error. - */ -export function makeRemoteWins(keys: ReadonlySet): (key: RemoteOverridableKey) => boolean { - return (key) => keys.has(key); -} - -/** Whether `block` provides a value at the dotted `key` path (scalar, array, or sub-table). */ -function blockProvidesKey(block: RawDoc, key: string): boolean { - let current: unknown = block; - for (const segment of key.split(".")) { - const record = asRecord(current); - if (record === undefined) return false; - current = record[segment]; - } - return current !== undefined; -} - -function applyRemoteOverride( - doc: RawDoc | undefined, - ref: string, - lookup: EnvLookup, -): RemoteOverride { - const remotes = asRecord(doc?.["remotes"]); - if (doc === undefined || remotes === undefined) return { doc, remoteOverrideKeys: new Set() }; - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - if (block === undefined) continue; - // Matches on the raw (env override > unexpanded TOML literal) project_id lookup, so - // blocks are selected before any `env(...)` literal is expanded. - if (resolveRemoteProjectId(name, block, lookup) === ref) { - const merged = deepMergeDoc(doc, block); - const blockSeed = asRecord(asRecord(block["db"])?.["seed"]); - // Record every env-overridable key the block supplies, so the resolution below - // suppresses the matching `SUPABASE_*` override. - const remoteOverrideKeys = new Set(); - for (const key of ENV_OVERRIDABLE_KEYS) { - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - // `auth.external.` is dynamically keyed (see AUTH_EXTERNAL_PROVIDER_FIELDS), - // so flatten whichever provider names this block actually supplies. - const externalBlock = asRecord(asRecord(block["auth"])?.["external"]); - if (externalBlock !== undefined) { - for (const providerName of Object.keys(externalBlock)) { - for (const field of AUTH_EXTERNAL_PROVIDER_FIELDS) { - const key = `auth.external.${providerName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - // Same dynamically-keyed handling for `auth.email.template.`/`notification.`. - const emailBlock = asRecord(block["auth"])?.["email"]; - const emailTemplateBlock = asRecord(asRecord(emailBlock)?.["template"]); - if (emailTemplateBlock !== undefined) { - for (const templateName of Object.keys(emailTemplateBlock)) { - for (const field of AUTH_EMAIL_TEMPLATE_FIELDS) { - const key = `auth.email.template.${templateName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - const emailNotificationBlock = asRecord(asRecord(emailBlock)?.["notification"]); - if (emailNotificationBlock !== undefined) { - for (const notificationName of Object.keys(emailNotificationBlock)) { - for (const field of AUTH_EMAIL_NOTIFICATION_FIELDS) { - const key = `auth.email.notification.${notificationName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - // `db.seed.enabled` is always override-tier: either the block set it, or it's - // forced false below when omitted — so env never overrides it on a matched-remote run. - remoteOverrideKeys.add("db.seed.enabled"); - if (blockSeed?.["enabled"] === undefined) { - return { - doc: deepMergeDoc(merged, { db: { seed: { enabled: false } } }), - appliedRemote: name, - remoteOverrideKeys, - }; - } - return { doc: merged, appliedRemote: name, remoteOverrideKeys }; - } - } - return { doc, remoteOverrideKeys: new Set() }; -} - -/** - * Config load aborts when two `[remotes.*]` blocks declare the same - * `project_id`. Returns the conflicting pair (current + prior block name) or - * `undefined`. - */ -function findDuplicateRemoteProjectId( - doc: RawDoc | undefined, - lookup: EnvLookup, -): { readonly name: string; readonly other: string } | undefined { - const remotes = asRecord(doc?.["remotes"]); - if (remotes === undefined) return undefined; - const seen = new Map(); - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - // Same raw project_id lookup used for block matching, so dedup uses the same identity. - const projectId = resolveRemoteProjectId(name, block, lookup); - if (projectId === undefined) continue; - const prior = seen.get(projectId); - if (prior !== undefined) return { name, other: prior }; - seen.set(projectId, name); - } - return undefined; -} - -/** - * Rejects any `[remotes.]` whose `project_id` is not a valid project - * ref, on every config load — so a malformed or missing remote `project_id` - * fails even local/direct commands before touching the database. Returns the - * first offending block name (object order) or `undefined`. - */ -function findInvalidRemoteProjectId( - doc: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const remotes = asRecord(doc?.["remotes"]); - if (remotes === undefined) return undefined; - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - // Validates the expanded project_id; an unset `env(...)` stays literal and still - // fails the ref pattern. (Block matching/dedup above use the raw literal.) - const projectId = resolveValidatedRemoteProjectId(name, block, lookup); - if (typeof projectId !== "string" || !PROJECT_REF_PATTERN.test(projectId)) { - return name; - } - } - return undefined; -} - const ENV_PATTERN = /^env\((.*)\)$/; -/** The variable name inside an `env(VAR)` reference, or `undefined` for any other string. */ -export function envRefName(value: string): string | undefined { +function envRefName(value: string): string | undefined { const matches = ENV_PATTERN.exec(value); return matches === null ? undefined : (matches[1] ?? ""); } -export function envRefNames(node: unknown): ReadonlyArray { - if (typeof node === "string") { - const name = envRefName(node); - return name === undefined ? [] : [name]; - } - return typeof node === "object" && node !== null ? Object.values(node).flatMap(envRefNames) : []; -} - -/** - * The substitution rule for an `env(VAR)` reference: the resolved value wins only when it - * is set and non-empty; otherwise the `env(VAR)` literal is preserved unchanged. Shared - * with the inspect report reader, which resolves the name through Effect's `Config`. - */ -export function envRefValue(literal: string, resolved: string | undefined): string { +function envRefValue(literal: string, resolved: string | undefined): string { return resolved !== undefined && resolved.length > 0 ? resolved : literal; } @@ -653,284 +193,22 @@ function expandEnv(value: string, lookup: (name: string) => string | undefined): return envRefValue(value, lookup(name)); } -/** `[db]` ports decode into `uint16`. */ -const MAX_PORT = 65535; - /** - * Resolve a `[db]` port field: the TOML value decodes into a `uint16`, and a - * quoted `env(VAR)` reference is expanded first, then parsed as the port. - * Resolution rules: - * - * - **Omitted** (`undefined`) → the schema default. - * - **Present and resolves to a `uint16`** (a plain integer in range, or an - * `env(VAR)` string that expands to one) → that value. - * - **Present but cannot unmarshal** (non-numeric, negative, out of range, or an - * unresolved `env(VAR)`) → `undefined`, signalling the caller to abort with - * `DbConfigLoadError` rather than silently defaulting and running - * against the default local database while hiding a broken config. + * The project `.env*` values the shell does not already set, for handlers that resolve global + * flags such as `--yes` against them. */ -function resolvePort(value: unknown, fallback: number, lookup: EnvLookup): number | undefined { - if (value === undefined) return fallback; - if (typeof value === "number") { - return Number.isInteger(value) && value >= 0 && value <= MAX_PORT ? value : undefined; - } - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - if (/^\d+$/.test(expanded)) { - const parsed = Number(expanded); - if (parsed <= MAX_PORT) return parsed; - } - } - return undefined; -} - -/** - * Resolve an optional integer config field (e.g. `db.major_version`): a - * quoted `env(VAR)` reference is expanded, then the result decodes into a - * `uint`, which strictly rejects a non-integer string like `17foo` rather - * than truncating it. Returns the parsed integer, `"absent"` when the field - * is omitted (caller uses the default), or `"invalid"` when present but not a - * whole non-negative integer (caller fails the load rather than silently - * defaulting and hiding a broken config). - */ -function resolveConfigInt(value: unknown, lookup: EnvLookup): number | "absent" | "invalid" { - if (value === undefined) return "absent"; - if (typeof value === "number") return Number.isInteger(value) ? value : "invalid"; - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - if (/^\d+$/.test(expanded)) return Number(expanded); - } - return "invalid"; -} - -function resolveStringSlice( - value: unknown, - fallback: ReadonlyArray, - lookup: EnvLookup, -): ReadonlyArray | undefined { - if (value === undefined) return fallback; - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - return expanded.length === 0 ? [] : expanded.split(","); - } - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === "string")) { - return undefined; - } - return value.map((item) => expandEnv(item, lookup)); -} - -/** - * Joins `pattern` under `supabase/`, collapsing `.`/`..` segments (e.g. - * `../seed.sql` → `seed.sql`). The cleaned, forward-slash-only path is the - * seed-tracking hash key, so an uncollapsed key would miss a previously - * recorded entry and re-run the seed. - */ -function joinSupabaseSeedPath(pattern: string): string { - const out: Array = []; - for (const segment of `supabase/${pattern}`.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); - else out.push(".."); - } else { - out.push(segment); - } - } - return out.length === 0 ? "." : out.join("/"); -} - -/** - * A bare leading separator (`/schemas`) has no Windows volume, so it's - * relative and joins under `supabase/` — unlike Node's win32 `isAbsolute`, - * which treats it as rooted at the current drive. - */ -const goIsAbs = (pathSvc: Path.Path, pattern: string): boolean => { - if (process.platform !== "win32") { - return pathSvc.isAbsolute(pattern); - } - const isSeparator = (c: string | undefined): boolean => c === "/" || c === "\\"; - // Drive-letter volume (`C:\`, `c:/`): `volumeNameLen` accepts any byte before - // `:` (case 2, `path[1] === ':'`), then `IsAbs` requires a separator right after. - if (pattern.length >= 3 && pattern[1] === ":" && isSeparator(pattern[2])) { - return true; - } - // UNC volume (`\\server\share`, `//server/share`): `IsAbs` treats a - // double-separator-prefixed volume as absolute unconditionally. - return pattern.length >= 2 && isSeparator(pattern[0]) && isSeparator(pattern[1]); -}; - -/** - * Resolves a single seed/schema-paths entry: a relative pattern is joined - * under `supabase/`; an absolute (or empty) pattern is returned verbatim. - * Used by the reader for `[db.seed].sql_paths` and - * `[db.migrations].schema_paths`, and by `db reset` for its `--sql-paths` - * override — all three feed the glob the same resolved paths. - */ -export const resolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => - pattern.length === 0 || goIsAbs(pathSvc, pattern) ? pattern : joinSupabaseSeedPath(pattern); - -/** `[db]` ports default through the development env unless `SUPABASE_ENV` overrides. */ -const DEFAULT_SUPABASE_ENV = "development"; - -export const configEnvOption = Effect.fnUntraced(function* (name: string) { - return yield* Config.option(Config.String(name)).pipe( - Effect.mapError( - () => new DbConfigLoadError({ message: `failed to resolve environment variable: ${name}` }), - ), +export const loadProjectEnvValues = (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => + loadCliProjectEnvFiles(workdir).pipe( + Effect.map((loaded) => ({ ...loaded.values })), + Effect.mapError((cause) => new DbConfigLoadError({ message: cause.message })), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), ); -}); - -export const resolveShellEnv = Effect.fnUntraced(function* (names: Iterable) { - const resolved = new Map(); - for (const name of new Set(names)) { - const value = yield* configEnvOption(name); - if (Option.isSome(value)) resolved.set(name, value.value); - } - return resolved; -}); - -/** - * Loads the project's nested `.env` files into a sparse lookup map without mutating - * `process.env` (first writer wins; the ambient environment always wins over any file). - */ -export const loadProjectEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, -) { - const configuredEnv = yield* configEnvOption("SUPABASE_ENV"); - const env = Option.getOrElse( - configuredEnv.pipe(Option.filter((value) => value.length > 0)), - () => DEFAULT_SUPABASE_ENV, - ); - const filenames = [`.env.${env}.local`]; - if (env !== "test") filenames.push(".env.local"); - filenames.push(`.env.${env}`, ".env"); - // `supabase/` is searched before the repo root; first writer wins. - const dirs = [path.join(workdir, "supabase"), workdir]; - const loaded: Record = {}; - for (const dir of dirs) { - for (const name of filenames) { - // A missing file is skipped; any other read error aborts rather than silently - // running with a broken env file. - const content = yield* fs.readFileString(path.join(dir, name)).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.succeed(Option.none())), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read environment file: ${name}`, - }), - ), - ), - ), - ), - ); - if (Option.isNone(content)) continue; - const parsed = yield* Effect.try({ - try: () => parseDotEnv(content.value), - catch: () => - new DbConfigLoadError({ message: `failed to parse environment file: ${name}` }), - }); - for (const [key, value] of Object.entries(parsed)) { - // The shell env and earlier files win; never overrides an already-set key. - if (loaded[key] !== undefined) continue; - const ambientValue = yield* configEnvOption(key); - if (Option.isNone(ambientValue)) loaded[key] = value; - } - } - } - return loaded; -}); function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** - * Resolve a `[section] enabled` style bool: a native TOML bool, or a string - * (including an `env(VAR)` reference) accepted by {@link parseGoBool}. - * Returns `"invalid"` for a malformed string; applies `fallback` when the key - * is absent. - */ -function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): boolean | "invalid" { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const parsed = parseGoBool(expandEnv(value, lookup)); - return parsed ?? "invalid"; - } - // A numeric value decodes as a bool (`value != 0`), so `enabled = 0` is an explicit - // false, not absent. - if (typeof value === "number") return value !== 0; - // A present non-scalar (e.g. `enabled = []`) is a decode error, not absent — it must - // not fall through to the default and silently pass a config that should have failed. - if (value === undefined) return fallback; - return "invalid"; -} - -/** - * `resolveBool` that fails the config load on a malformed bool. `envValue` is - * the `SUPABASE_*` env override, which wins over the TOML value/default when - * set; an `env(VAR)` indirection in the override is expanded before parsing. - */ -const resolveBoolOrFail = Effect.fnUntraced(function* ( - field: string, - value: unknown, - fallback: boolean, - lookup: EnvLookup, - envValue?: string, -) { - if (envValue !== undefined) { - const parsed = parseGoBool(expandEnv(envValue, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return parsed; - } - const resolved = resolveBool(value, fallback, lookup); - if (resolved === "invalid") { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return resolved; -}); - -/** - * Tri-state sibling of `resolveBoolOrFail` for fields that stay `None` (never - * `false`) when absent. The `SUPABASE_*` env override wins when present; - * otherwise a present TOML bool/string is decoded with {@link parseGoBool}, - * and a malformed value aborts the load. - */ -const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( - field: string, - envValue: string | undefined, - value: unknown, - lookup: EnvLookup, -) { - if (envValue !== undefined) { - const parsed = parseGoBool(expandEnv(envValue, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return Option.some(parsed); - } - if (typeof value === "boolean") return Option.some(value); - // A numeric value decodes the same way: `value != 0`. - if (typeof value === "number") return Option.some(value !== 0); - if (typeof value === "string") { - const parsed = parseGoBool(expandEnv(value, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return Option.some(parsed); - } - // A present non-scalar value is a decode failure, not absent — reject it here - // rather than silently treating it as `None`. - if (value === undefined) return Option.none(); - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); -}); - const VAULT_SECRET_PATH = ["db", "vault", "*"] as const; /** @@ -1037,539 +315,159 @@ export const assertDecryptableSecrets = ( return undefined; }; -// An absent `auth.site_url` defaults to this value; only an explicit empty string fails. -const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.length === 0 ? undefined : value; -const ENV_OVERRIDE_NAMES = [ - "SUPABASE_PROJECT_ID", - "SUPABASE_DB_PORT", - "SUPABASE_DB_SHADOW_PORT", - "SUPABASE_DB_MAJOR_VERSION", - "SUPABASE_DB_ORIOLEDB_VERSION", - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", - "SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH", - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - "SUPABASE_AUTH_ENABLED", - "SUPABASE_ANALYTICS_BACKEND", - "SUPABASE_ANALYTICS_ENABLED", - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - "SUPABASE_DB_MIGRATIONS_ENABLED", - "SUPABASE_DB_SEED_ENABLED", - "SUPABASE_DB_SEED_SQL_PATHS", - "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", - "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", - "SUPABASE_API_SCHEMAS", - "SUPABASE_EXPERIMENTAL_PG_DELTA", -] as const; +const causeMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); -type EnvOverrideName = (typeof ENV_OVERRIDE_NAMES)[number]; +const parseErrorMessage = (cause: unknown): string => { + const detail = causeMessage(cause); + const head = detail.split("\n at ")[0] ?? detail; + return /^(Missing required|Invalid (?!TOML|JSON))/.test(head) + ? head + : `failed to load config: ${detail}`; +}; -/** - * The `SUPABASE_*` override lookup `readDbToml` exposes as `envLookup`: a non-empty shell value - * wins, then a non-empty value from the project `.env` files. - */ -export const envOverrideLookup = Effect.fnUntraced(function* ( - projectEnv: Readonly>, -) { - const shellOverrideEnv = yield* resolveShellEnv(ENV_OVERRIDE_NAMES); - return (name: EnvOverrideName): string | undefined => { - const fromShell = shellOverrideEnv.get(name); - if (fromShell !== undefined && fromShell.length > 0) return fromShell; - const fromFile = projectEnv[name]; - return fromFile !== undefined && fromFile.length > 0 ? fromFile : undefined; - }; -}); +type ResolvedConfigLoadError = Effect.Error>; -/** - * Prints the `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` deprecation WARN on stderr when `envLookup` - * finds it set. Commands call it once after their main config read; a command that loads the - * local project context gets the WARN from that load instead. - */ -export const warnDeprecatedOrioleDBVersionEnv = Effect.fnUntraced(function* ( - envLookup: DbTomlValues["envLookup"], -) { - const warning = yield* deprecatedOrioleDBVersionEnvWarning(envLookup); - if (warning !== undefined) yield* (yield* Output).raw(`${warning}\n`, "stderr"); -}); +const toDbConfigLoadError = (error: ResolvedConfigLoadError) => { + if (isPassthroughLoadFailure(error)) return error; + switch (error._tag) { + case "CliConfigParseError": + return new DbConfigLoadError({ message: parseErrorMessage(error.cause) }); + case "PlatformError": + return new DbConfigLoadError({ message: `failed to read file config: ${error.message}` }); + default: + return new DbConfigLoadError({ message: error.message }); + } +}; + +const loadDbTomlResolvedConfig = ( + workdir: string, + ref: string | undefined, + ignoreConfigFile: boolean, +) => + CliConfigValues.use((values) => + values.load({ + workdir, + projectRef: Option.fromNullishOr(ref), + ...(ignoreConfigFile ? { ignoreConfigFile: true as const } : {}), + }), + ).pipe(Effect.mapError(toDbConfigLoadError)); /** - * Reads `/supabase/config.toml` (db subtree + project id). `fs`/`path` are passed in so - * the resolver can capture them once and keep its own `R` at `never`. + * Projects the `CliConfigValues` resolved config of `/supabase/config.{toml,json}` (flags aside) + * onto {@link DbTomlValues}. `fs`/`path` are passed in so the resolver can capture them once and + * keep its own `R` at `never`. * - * Fails with `DbConfigLoadError` only when the config file is present but unparseable; an absent - * file is not an error. + * Fails with `DbConfigLoadError` when the config is present but unreadable, undecodable or invalid; + * an absent file is not an error. */ const readDbTomlCore = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - // When set, the `[remotes.]` block whose `project_id` equals `ref` is merged - // over the base config before fields are read; omitted for `--local`/`--db-url`/declarative. + // The `[remotes.]` block whose `project_id` equals `ref` is the config-tier overlay; + // omitted for `--local`/`--db-url`/declarative. ref?: string, - // Internal: when true, `config.toml` is treated as absent so the body resolves pure - // defaults (env overrides still apply). Used as the fallback after a config-load - // failure by the lenient `readDbToml({ validate: false })` wrapper. + // The config file is treated as absent: the fallback after a load failure for `validate: false`. ignoreConfigFile = false, - // Internal: gates the OrioleDB S3 `assertEnvLoaded` WARN below so it prints at most - // once per command invocation. Callers that re-read the config internally after an - // earlier same-invocation read pass `false`. + // Gates the OrioleDB S3 unresolved-env WARN so it prints once per command invocation. warnOnUnresolvedEnv = true, resolveVaultSecrets = true, ) { - const supabaseDir = path.join(workdir, "supabase"); - const configPath = path.join(supabaseDir, "config.toml"); - - // A missing file yields defaults; any other read error aborts rather than silently - // running against the default local database. - const maybeContent = ignoreConfigFile - ? Option.none() - : yield* fs.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.succeed(Option.none())), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), - ), - ), - ), - ); - - // Built before the remote-config validation/merge below, so remote and top-level - // `project_id` env() forms are expanded before they are validated or used to derive - // Docker IDs. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - let doc: RawDoc | undefined; - if (Option.isSome(maybeContent)) { - doc = yield* Effect.try({ - try: () => asRecord(SmolToml.parse(maybeContent.value)), - catch: (cause) => - new DbConfigLoadError({ - message: `failed to load config: ${configParseCauseMessage(cause)}`, - }), - }); - // Same per-section promotion as the config loader, before the remote merge; the loader owns - // the deprecation warning. - doc = asRecord(normalizeDeprecatedOrioleDBVersion(doc).document); - } - // `SUPABASE_DB_*` env vars override the matching `[db]` field before the TOML - // value/default. - const envOverride = yield* envOverrideLookup(projectEnv); - const shellRefEnv = yield* resolveShellEnv([ - ...envRefNames(doc), - ...Object.keys(asRecord(doc?.["remotes"]) ?? {}).map(remoteProjectIdEnvName), - ...ENV_OVERRIDE_NAMES.flatMap((name) => envRefNames(envOverride(name))), - ]); - const lookup: EnvLookup = (name) => shellRefEnv.get(name) ?? projectEnv[name]; - // dotenvx private keys for decrypting `encrypted:` secrets below. - const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnv, ...process.env }); - - let db: RawDoc | undefined; - let pgDeltaRaw: RawDoc | undefined; - let authRaw: RawDoc | undefined; - let storageRaw: RawDoc | undefined; - let realtimeRaw: RawDoc | undefined; - let apiRaw: RawDoc | undefined; - let edgeRuntimeRaw: RawDoc | undefined; - let experimentalRaw: RawDoc | undefined; - let functionsRaw: RawDoc | undefined; - let analyticsRaw: RawDoc | undefined; - let projectId = Option.none(); - // A present `project_id` that env-expands to empty; the `SUPABASE_PROJECT_ID` - // override below may still rescue it before the check further down fails the load. - let projectIdExplicitEmpty = false; - // Keys a matched remote block set at override tier; they must beat matching env - // overrides below. - let remoteOverrideKeys: ReadonlySet = new Set(); - // The matched `[remotes.]` block name, echoed as the config-override line. - let appliedRemote: string | undefined; - if (Option.isSome(maybeContent)) { - // Config load aborts when two `[remotes.*]` blocks share a `project_id`, - // regardless of which command runs — check before merging. - const duplicateRemote = findDuplicateRemoteProjectId(doc, lookup); - if (duplicateRemote !== undefined) { - return yield* new DbConfigLoadError({ - message: `duplicate project_id for [remotes.${duplicateRemote.name}] and [remotes.${duplicateRemote.other}]`, - }); - } - // Validation rejects any remote whose `project_id` is not a valid 20-char ref, on - // every load, after the duplicate check. So a malformed remote fails even - // local/direct commands before any DB connection. - const invalidRemote = findInvalidRemoteProjectId(doc, lookup); - if (invalidRemote !== undefined) { - return yield* new DbConfigLoadError({ - message: `Invalid config for remotes.${invalidRemote}.project_id. Must be like: abcdefghijklmnopqrst`, - }); - } - // Apply a matching `[remotes.]` override: merge the block whose - // `project_id` equals the resolved ref over the base. - const remoteOverride = - ref === undefined - ? { doc, remoteOverrideKeys: new Set() } - : applyRemoteOverride(doc, ref, lookup); - const effectiveDoc = remoteOverride.doc; - remoteOverrideKeys = remoteOverride.remoteOverrideKeys; - appliedRemote = remoteOverride.appliedRemote; - db = asRecord(effectiveDoc?.["db"]); - experimentalRaw = asRecord(effectiveDoc?.["experimental"]); - pgDeltaRaw = asRecord(experimentalRaw?.["pgdelta"]); - authRaw = asRecord(effectiveDoc?.["auth"]); - storageRaw = asRecord(effectiveDoc?.["storage"]); - realtimeRaw = asRecord(effectiveDoc?.["realtime"]); - apiRaw = asRecord(effectiveDoc?.["api"]); - edgeRuntimeRaw = asRecord(effectiveDoc?.["edge_runtime"]); - functionsRaw = asRecord(effectiveDoc?.["functions"]); - analyticsRaw = asRecord(effectiveDoc?.["analytics"]); - // Expand `env(VAR)` before deriving the Docker container name from `project_id` — - // otherwise `project_id = "env(PROJECT_ID)"` would sanitize to the literal string. - const rawProjectId = effectiveDoc?.["project_id"]; - projectId = nonEmptyString( - typeof rawProjectId === "string" ? expandEnv(rawProjectId, lookup) : rawProjectId, - ); - // A present `project_id` string that resolves to empty is a "kept empty override". - projectIdExplicitEmpty = typeof rawProjectId === "string" && Option.isNone(projectId); - - // Every secret-typed field must be decryptable before validation/connecting. Skips - // `[db.vault]` when the caller opts out, so the vault loop below only materializes - // already-proven-decryptable values. - const secretError = assertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys, { - includeVault: resolveVaultSecrets, - }); - if (secretError !== undefined) { - return yield* new DbConfigLoadError({ message: secretError }); - } - } - // `remoteOverrideKeys` has its final value from here on — see `makeRemoteWins`'s own doc - // comment for why this is typed narrower than the `ReadonlySet` it wraps. - const remoteWins = makeRemoteWins(remoteOverrideKeys); - - // `SUPABASE_PROJECT_ID` overrides the top-level `project_id` used to name the local - // stack's Docker resources, unless a matched `[remotes.]` block already set - // `project_id` at override tier — that value must win over a stale/differently-scoped - // env var. - const projectIdEnv = remoteWins("project_id") ? undefined : envOverride("SUPABASE_PROJECT_ID"); - if (projectIdEnv !== undefined) { - projectId = nonEmptyString(expandEnv(projectIdEnv, lookup)); - } - - // An absent `project_id` is tolerated here (deferred); a present `project_id = ""` - // that the env override didn't rescue fails the load, so a destructive command (e.g. - // remote `db reset`) fails fast instead of running against a config that should have - // already failed validation. - if (projectIdExplicitEmpty && Option.isNone(projectId)) { - return yield* new DbConfigLoadError({ - message: "Missing required field in config: project_id", - }); - } + const resolvedConfig = yield* loadDbTomlResolvedConfig(workdir, ref, ignoreConfigFile); + const projectEnv = resolvedConfig.projectEnvValues; + const { config } = resolvedConfig.materialized; + const fail = (message: string) => Effect.fail(new DbConfigLoadError({ message })); + const getKey = (key: CliConfigKey) => + resolvedConfig.get(key).pipe(Effect.mapError(toDbConfigLoadError)); - // A present-but-unmarshalable port aborts rather than defaulting, so a broken `[db]` - // config never silently targets the default local database. - const port = resolvePort( - (remoteWins("db.port") ? undefined : envOverride("SUPABASE_DB_PORT")) ?? db?.["port"], - DEFAULT_PORT, - lookup, + const declaredDocument = resolvedConfig.loaded.document ?? {}; + const secretDocument = Object.fromEntries( + ["db", "auth", "studio", "edge_runtime", "remotes"].map((key) => [key, declaredDocument[key]]), ); - const shadowPort = resolvePort( - (remoteWins("db.shadow_port") ? undefined : envOverride("SUPABASE_DB_SHADOW_PORT")) ?? - db?.["shadow_port"], - DEFAULT_SHADOW_PORT, + const referenced = yield* resolvedConfig + .envValues(envReferenceNames(secretDocument)) + .pipe(Effect.mapError(toDbConfigLoadError)); + const lookup: EnvLookup = (name) => referenced[name]; + const secretError = assertDecryptableSecrets( + secretDocument, lookup, + resolvedConfig.dotenvPrivateKeys, + { + includeVault: resolveVaultSecrets, + }, ); - if (port === undefined || shadowPort === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to load config: invalid ${port === undefined ? "db.port" : "db.shadow_port"} value`, - }); - } - // An explicit `db.port = 0` is a load error (an absent port is defaulted first); - // `resolvePort` accepts 0 as a valid uint16, so the zero check lives here. No - // equivalent check for `shadow_port`. - if (port === 0) { - return yield* new DbConfigLoadError({ message: "Missing required field in config: db.port" }); - } - - // `db.password` isn't part of the config schema — it's a TS-only extension for - // `--local` connections. Must not read `DB_PASSWORD` (linked-only), or `db query - // --local` etc. would authenticate with a remote secret. - const passwordRaw = typeof db?.["password"] === "string" ? db["password"] : undefined; + if (secretError !== undefined) return yield* fail(secretError); - // `env(VAR)` is expanded, then the result must decode as a whole integer (`17foo` is - // not truncated to 17); `SUPABASE_DB_MAJOR_VERSION` overrides the TOML value. - const majorVersionRaw = - (remoteWins("db.major_version") ? undefined : envOverride("SUPABASE_DB_MAJOR_VERSION")) ?? - db?.["major_version"]; - const majorVersionResolved = resolveConfigInt(majorVersionRaw, lookup); - if (majorVersionResolved === "invalid") { - // Present but not a whole integer (`17foo`, or an `env(VAR)` that does not - // resolve to digits): fail the config parse rather than defaulting. - const shown = - typeof majorVersionRaw === "string" - ? expandEnv(majorVersionRaw, lookup) - : String(majorVersionRaw); - return yield* new DbConfigLoadError({ - message: `Failed reading config: Invalid db.major_version: ${shown}.`, - }); + const configuredProjectId = yield* getKey(CliConfigKeys.projectId); + if ( + configuredProjectId.origin.tier !== "default" && + (configuredProjectId.unnormalized ?? configuredProjectId.value) === "" + ) { + return yield* fail("Missing required field in config: project_id"); } - // An unsupported major version is rejected by the single `validateResolvedConfig` - // call below; an absent value defaults first, a present one (including 0) flows through. - const majorVersion = - typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; + const projectId = configuredProjectId.value; - // On a 15/17 project, validation rewrites the Postgres image to the OrioleDB tag and - // checks the four S3 fields below; the image rewrite itself happens in `resolveDbImage`. - const expandString = (value: unknown): Option.Option => - typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); - const orioledbVersionRaw = - (remoteWins("db.orioledb_version") ? undefined : readOrioleDBVersionEnvOverride(envOverride)) ?? - db?.["orioledb_version"]; - const orioledbVersion = expandString(orioledbVersionRaw); - // The defaults-only fallback says nothing about the project's engine. + const { port, shadow_port: shadowPort, major_version: majorVersion } = config.db; + if (port === 0) return yield* fail("Missing required field in config: db.port"); + + const orioledbVersion = nonEmptyString(config.db.orioledb_version); if (!ignoreConfigFile) { yield* recordOrioleDbTelemetry(Option.getOrUndefined(orioledbVersion), majorVersion); } - if (selectsOrioleDb(Option.getOrUndefined(orioledbVersion), majorVersion)) { - // Warns (does not fail) when an S3 field still holds an unexpanded `env(VAR)`; - // matches the established stderr line, with the env var name from the capture. - const s3Fields = ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const; - for (const field of s3Fields) { - const raw = experimentalRaw?.[field]; - if (typeof raw !== "string") continue; - const expanded = expandEnv(raw, lookup); - const unset = ENV_PATTERN.exec(expanded); - if (unset !== null && warnOnUnresolvedEnv) { + if ( + selectsOrioleDb(Option.getOrUndefined(orioledbVersion), majorVersion) && + warnOnUnresolvedEnv + ) { + for (const field of ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const) { + const unset = ENV_PATTERN.exec(config.experimental[field] ?? ""); + if (unset !== null) { process.stderr.write(`WARN: environment variable is unset: ${unset[1] ?? ""}\n`); } } } - // Selects the edge-runtime image tag (`deno1` when 1, otherwise the default); pg-delta - // needs it to pick the matching image. `SUPABASE_EDGE_RUNTIME_DENO_VERSION` overrides - // the TOML value before validation. - const denoVersionRaw = - (remoteWins("edge_runtime.deno_version") - ? undefined - : envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION")) ?? edgeRuntimeRaw?.["deno_version"]; - // A present non-integer string or unresolved `env(MISSING)` aborts the load rather - // than falling through to the default Deno 2 image. - const denoVersionResolved = resolveConfigInt(denoVersionRaw, lookup); - if (denoVersionResolved === "invalid") { - const shown = - typeof denoVersionRaw === "string" - ? expandEnv(denoVersionRaw, lookup) - : String(denoVersionRaw); - return yield* new DbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${shown}.`, - }); - } - // An invalid deno_version is rejected by the single `validateResolvedConfig` call - // below; an absent key falls through to the default (2). - const denoVersion = - typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; + const denoVersion = config.edge_runtime.deno_version; - // `[experimental.webhooks]` only exists to be turned ON: any present section whose - // `enabled` isn't explicitly `true` is rejected. Unlike `pgdelta.enabled` below, - // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED` only applies when the section itself is - // declared — gate the env read on `webhooksPresent`, not just later validation. - const webhooksRaw = asRecord(experimentalRaw?.["webhooks"]); - const webhooksPresent = webhooksRaw !== undefined; - const webhooksEnabledRaw = webhooksRaw?.["enabled"]; - const webhooksEnabledEnv = webhooksPresent - ? remoteWins("experimental.webhooks.enabled") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED") - : undefined; - let webhooksEnabled: boolean; - if (webhooksEnabledEnv !== undefined) { - const expandedWebhooksEnabledEnv = expandEnv(webhooksEnabledEnv, lookup); - const parsed = parseGoBool(expandedWebhooksEnabledEnv); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandedWebhooksEnabledEnv}.`, - }); - } - webhooksEnabled = parsed; - } else if (typeof webhooksEnabledRaw === "boolean") { - webhooksEnabled = webhooksEnabledRaw; - } else if (typeof webhooksEnabledRaw === "number") { - // A numeric `enabled = 1` is true (`value != 0`), same as `experimental.pgdelta.enabled` below. - webhooksEnabled = webhooksEnabledRaw !== 0; - } else if (typeof webhooksEnabledRaw === "string") { - const parsed = parseGoBool(expandEnv(webhooksEnabledRaw, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandEnv(webhooksEnabledRaw, lookup)}.`, - }); - } - webhooksEnabled = parsed; - } else { - webhooksEnabled = false; - } + const webhooksPresent = resolvedConfig.declares("experimental.webhooks"); + const webhooksEnabled = config.experimental.webhooks?.enabled ?? false; + const pgDeltaConfig = config.experimental.pgdelta; + const declarativeSchemaPath = nonEmptyString(pgDeltaConfig?.declarative_schema_path); + const formatOptions = nonEmptyString(pgDeltaConfig?.format_options); - // `[experimental.pgdelta]`. `enabled` accepts a TOML bool or a string (including - // `env(VAR)`); `declarative_schema_path` is resolved to a `supabase/`-prefixed path - // when relative. `SUPABASE_EXPERIMENTAL_PGDELTA_*` overrides the TOML before - // validation. - const enabledRaw = pgDeltaRaw?.["enabled"]; - const enabledEnv = remoteWins("experimental.pgdelta.enabled") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"); - // `"1"` counts as true and a malformed value aborts the load. The env override wins, - // then the TOML bool, then an `env(VAR)` string, defaulting to false when absent. - let enabled: boolean; - if (enabledEnv !== undefined) { - // An `env(VAR)` indirection in the override is expanded before the bool parse. - const expandedEnabledEnv = expandEnv(enabledEnv, lookup); - const parsed = parseGoBool(expandedEnabledEnv); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandedEnabledEnv}.`, - }); - } - enabled = parsed; - } else if (typeof enabledRaw === "boolean") { - enabled = enabledRaw; - } else if (typeof enabledRaw === "number") { - // A numeric `enabled = 1` is true (`value != 0`), same rule as the generic `resolveBool`. - enabled = enabledRaw !== 0; - } else if (typeof enabledRaw === "string") { - const parsed = parseGoBool(expandEnv(enabledRaw, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandEnv(enabledRaw, lookup)}.`, - }); - } - enabled = parsed; - } else { - enabled = false; - } - - const declarativeSchemaPathRaw = pgDeltaRaw?.["declarative_schema_path"]; - // Expand `env(VAR)` in whichever source wins (override or TOML literal) before the - // path is used (`expandEnv` is a no-op on a non-`env()` string). - const declarativeSchemaPathValue = expandEnv( - (remoteWins("experimental.pgdelta.declarative_schema_path") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH")) ?? - (typeof declarativeSchemaPathRaw === "string" ? declarativeSchemaPathRaw : ""), - lookup, - ); - let declarativeSchemaPath = Option.none(); - if (declarativeSchemaPathValue.length > 0) { - declarativeSchemaPath = Option.some( - path.isAbsolute(declarativeSchemaPathValue) - ? declarativeSchemaPathValue - : path.join("supabase", declarativeSchemaPathValue), - ); - } - - const formatOptionsRaw = pgDeltaRaw?.["format_options"]; - // Expand the resolved value (env override or TOML literal) before the JSON validation - // below runs. - const formatOptionsExpanded = expandEnv( - (remoteWins("experimental.pgdelta.format_options") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS")) ?? - (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), - lookup, - ); - // A non-empty, non-JSON `format_options` is rejected by the single - // `validateResolvedConfig` call below. - const formatOptions = nonEmptyString(formatOptionsExpanded); - - // Bucket-name/function-slug validation lives in `validateResolvedConfig` (called - // once, below); only the pure extraction stays here. - const bucketsRaw = asRecord(storageRaw?.["buckets"]); - // Validates each bucket's own `file_size_limit` eagerly, so a malformed value is - // caught even on a restart that never reaches Storage bring-up. Validate-only — - // `seedBucketsRun` re-parses the real value when it actually seeds. - if (bucketsRaw !== undefined) { - for (const [bucketName, bucketRaw] of Object.entries(bucketsRaw)) { - const rawLimit = asRecord(bucketRaw)?.["file_size_limit"]; - if (typeof rawLimit !== "string" && typeof rawLimit !== "number") continue; - const limitString = - typeof rawLimit === "number" ? String(rawLimit) : expandEnv(rawLimit, lookup); - yield* Effect.try({ - try: () => ramInBytes(limitString), - catch: () => - new DbConfigLoadError({ - message: `failed to parse config: invalid storage.buckets.${bucketName}.file_size_limit.`, - }), - }); - } + const buckets = config.storage.buckets ?? {}; + for (const [bucketName, bucket] of Object.entries(buckets)) { + yield* Effect.try({ + try: () => ramInBytes(bucket.file_size_limit), + catch: () => + new DbConfigLoadError({ + message: `failed to parse config: invalid storage.buckets.${bucketName}.file_size_limit.`, + }), + }); } - // Gated on `auth.enabled` (default true); `SUPABASE_AUTH_ENABLED` decides whether the - // auth block is validated, unless a matched remote block set `auth.enabled` itself. - const authEnabled = yield* resolveBoolOrFail( - "auth.enabled", - authRaw?.["enabled"], - true, - lookup, - remoteWins("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), - ); + const authEnabled = config.auth.enabled; + const { auth } = config; - // `str`/`gate`/`fail` build `AuthInput` for the shared validator call below, and back - // the sms/external checks further down that only this reader performs. - const fail = (message: string) => Effect.fail(new DbConfigLoadError({ message })); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved - // `env(VAR)` stays literal (non-empty). - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? expandEnv(value, lookup) : ""; - }; - // Accepts a boolean, nonzero number, or parseable string; a malformed string aborts - // the load rather than coercing to false. Absent/non-string → false. - const gate = (rec: RawDoc | undefined, key: string, field: string) => - Effect.gen(function* () { - const value = rec?.[key]; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return false; - const parsed = parseGoBool(expandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); - - const authRawResolved = authRaw ?? {}; let authInput: AuthInput | undefined; if (authEnabled) { - // A1: site_url required. - const siteUrl = - authRawResolved["site_url"] === undefined - ? DEFAULT_AUTH_SITE_URL - : str(authRawResolved, "site_url"); - - // A4: [auth.captcha]. The provider enum check and the `enabled`-gated - // required-field checks both live in `validateResolvedConfig`. - const captchaRaw = asRecord(authRawResolved["captcha"]); - let captchaInput: CaptchaInput | undefined; - if (captchaRaw !== undefined) { - const provider = str(captchaRaw, "provider"); - const secret = str(captchaRaw, "secret"); - captchaInput = { - enabled: yield* gate(captchaRaw, "enabled", "auth.captcha.enabled"), - // `str()` returns `""` for an absent key, but the shared validator's - // `provider === undefined` check needs a real `undefined` to fire correctly for an - // enabled captcha with no provider set. - provider: provider.length > 0 ? provider : undefined, - secret: secret.length > 0 ? secret : undefined, - }; - } - - // A5: signing keys file load (I/O). A relative path resolves under the supabase - // dir; absolute is verbatim. - const signingKeysPath = str(authRawResolved, "signing_keys_path"); + const captcha = auth.captcha; + const captchaInput: CaptchaInput | undefined = + captcha === undefined + ? undefined + : { + enabled: captcha.enabled, + provider: nonEmpty(captcha.provider), + secret: nonEmpty(captcha.secret), + }; + + const signingKeysPath = auth.signing_keys_path ?? ""; if (signingKeysPath.length > 0) { const keysJson = yield* fs - .readFileString(resolveSigningKeysPath(workdir, signingKeysPath)) + .readFileString(resolveSigningKeysPath(path, workdir, signingKeysPath)) .pipe( Effect.mapError( (cause) => new DbConfigLoadError({ message: signingKeysReadErrorMessage(cause) }), @@ -1590,197 +488,136 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } - // A6: passkey/webauthn when passkey enabled. - const passkeyRaw = asRecord(authRawResolved["passkey"]); let passkeyInput: PasskeyInput | undefined; - if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { - const webauthnRaw = asRecord(authRawResolved["webauthn"]); - const rpOriginsRaw = webauthnRaw?.["rp_origins"]; - // `rp_origins` may be a comma-separated string (raw or `env(...)`-resolved) rather - // than a literal TOML array; split it, matching `local-config-values.ts`'s - // handling of this same field. - const rpOrigins = Array.isArray(rpOriginsRaw) - ? rpOriginsRaw - : strToArr(str(webauthnRaw, "rp_origins")); + if ((yield* getKey(CliConfigKeys.auth.passkey.enabled)).value) { + const rpOrigins = (yield* getKey(CliConfigKeys.auth.webauthn.rpOrigins)).value; passkeyInput = { - webauthnPresent: webauthnRaw !== undefined, - rpId: str(webauthnRaw, "rp_id"), + webauthnPresent: resolvedConfig.declares("auth.webauthn"), + rpId: (yield* getKey(CliConfigKeys.auth.webauthn.rpId)).value, rpOrigins: rpOrigins.length > 0 ? rpOrigins : undefined, }; } - // B1: hooks — each enabled hook, checked in this fixed order. - const hookRaw = asRecord(authRawResolved["hook"]); - const hookTypes = [ + const hooks: Array = []; + for (const type of [ "mfa_verification_attempt", "password_verification_attempt", "custom_access_token", "send_sms", "send_email", "before_user_created", - ] as const; - const hooks: Array = []; - for (const hookType of hookTypes) { - const h = asRecord(hookRaw?.[hookType]); - if (h !== undefined && (yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) { - hooks.push({ type: hookType, uri: str(h, "uri"), secrets: str(h, "secrets") }); - } + ] as const) { + const hook = auth.hook[type]; + if (hook.enabled) hooks.push({ type, uri: hook.uri ?? "", secrets: hook.secrets ?? "" }); } - // B2: mfa — enroll requires verify, fixed totp/phone/web_authn order. - const mfaRaw = asRecord(authRawResolved["mfa"]); - const mfa: Array = []; - for (const label of ["totp", "phone", "web_authn"] as const) { - const factor = asRecord(mfaRaw?.[label]); - mfa.push({ - label, - enrollEnabled: yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`), - verifyEnabled: yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`), - }); - } + const mfa: Array = (["totp", "phone", "web_authn"] as const).map((label) => ({ + label, + enrollEnabled: auth.mfa[label].enroll_enabled, + verifyEnabled: auth.mfa[label].verify_enabled, + })); - // B3: email — template/notification content is I/O. Config loading resolves every - // relative `content_path` from the project root; absolute paths remain unchanged. - const emailRaw = asRecord(authRawResolved["email"]); - const templatesRaw = asRecord(emailRaw?.["template"]); - if (templatesRaw !== undefined) { - for (const name of Object.keys(templatesRaw)) { - const tmpl = asRecord(templatesRaw[name]); - if (tmpl === undefined) continue; - const contentPath = yield* Effect.try({ - try: () => - resolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, + const emailContentPath = Effect.fnUntraced(function* ( + section: "template" | "notification", + name: string, + entry: { readonly content_path?: string }, + ) { + const family = cliConfigRegistry.families.find( + (candidate) => + candidate.id === (section === "template" ? "authEmailTemplate" : "authEmailNotification"), + ); + const contentKey = + family === undefined ? undefined : cliConfigFamilyKey(family, name, "content"); + const contentPresent = + contentKey !== undefined && (yield* getKey(contentKey)).origin.tier !== "default"; + return yield* resolveEmailTemplateContentPath({ + section, + name, + contentPath: entry.content_path ?? "", + contentPresent, + base: workdir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (error) => + new DbConfigLoadError({ + message: causeMessage( + error instanceof ConfigValidateError ? error : fsErrorCause(error), + ), }), - catch: (cause) => + ), + ); + }); + for (const [name, template] of Object.entries(auth.email.template)) { + const contentPath = yield* emailContentPath("template", name, template); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), + message: emailContentPathReadErrorMessage("template", name, cause), }), - }); - if (contentPath === undefined) continue; - yield* fs.readFileString(contentPath).pipe( - Effect.mapError( - (cause) => - new DbConfigLoadError({ - message: emailContentPathReadErrorMessage("template", name, cause), - }), - ), - ); - } + ), + ); } - const notificationsRaw = asRecord(emailRaw?.["notification"]); - if (notificationsRaw !== undefined) { - for (const name of Object.keys(notificationsRaw)) { - const tmpl = asRecord(notificationsRaw[name]); - if ( - tmpl === undefined || - !(yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) - ) { - continue; - } - const contentPath = yield* Effect.try({ - try: () => - resolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => + for (const [name, notification] of Object.entries(auth.email.notification)) { + if (!notification.enabled) continue; + const contentPath = yield* emailContentPath("notification", name, notification); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), + message: emailContentPathReadErrorMessage("notification", name, cause), }), - }); - if (contentPath === undefined) continue; - yield* fs.readFileString(contentPath).pipe( - Effect.mapError( - (cause) => - new DbConfigLoadError({ - message: emailContentPathReadErrorMessage("notification", name, cause), - }), - ), - ); - } - } - // A present `[auth.email.smtp]` table defaults `enabled` to true unless explicitly - // disabled. - const smtpRaw = asRecord(emailRaw?.["smtp"]); - let smtpInput: SmtpInput | undefined; - if (smtpRaw !== undefined) { - const smtpPortRaw = smtpRaw["port"]; - // The shared validator's required-field check is `port === 0`. A non-numeric `port` - // (or unresolved `env(VAR)`) parses to `NaN` here — normalize that to `0` so it - // still trips the check instead of silently passing. - const smtpPortNumeric = - typeof smtpPortRaw === "number" - ? smtpPortRaw - : typeof smtpPortRaw === "string" - ? Number(expandEnv(smtpPortRaw, lookup)) - : 0; - smtpInput = { - enabled: - smtpRaw["enabled"] === undefined - ? true - : yield* gate(smtpRaw, "enabled", "auth.email.smtp.enabled"), - host: str(smtpRaw, "host"), - port: Number.isNaN(smtpPortNumeric) ? 0 : smtpPortNumeric, - user: str(smtpRaw, "user"), - pass: str(smtpRaw, "pass"), - adminEmail: str(smtpRaw, "admin_email"), - }; + ), + ); } - // B6: third_party — each enabled provider, checked in this fixed order. - // `aws_cognito`'s error messages say "cognito", not "aws_cognito". - const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const smtp = auth.email.smtp; + const smtpInput: SmtpInput | undefined = + smtp === undefined + ? undefined + : { + enabled: resolveSmtpEnabled(resolvedConfig), + host: smtp.host ?? "", + port: smtp.port ?? 0, + user: smtp.user ?? "", + pass: smtp.pass ?? "", + adminEmail: smtp.admin_email ?? "", + }; + + const thirdPartyConfig = auth.third_party; const thirdParty: Array = []; - const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); - if ( - firebaseRaw !== undefined && - (yield* gate(firebaseRaw, "enabled", "auth.third_party.firebase.enabled")) - ) { - thirdParty.push({ provider: "firebase", requiredField: str(firebaseRaw, "project_id") }); + if (thirdPartyConfig.firebase.enabled) { + thirdParty.push({ + provider: "firebase", + requiredField: thirdPartyConfig.firebase.project_id ?? "", + }); } - const auth0Raw = asRecord(thirdPartyRaw?.["auth0"]); - if ( - auth0Raw !== undefined && - (yield* gate(auth0Raw, "enabled", "auth.third_party.auth0.enabled")) - ) { - thirdParty.push({ provider: "auth0", requiredField: str(auth0Raw, "tenant") }); + if (thirdPartyConfig.auth0.enabled) { + thirdParty.push({ provider: "auth0", requiredField: thirdPartyConfig.auth0.tenant ?? "" }); } - const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); - if ( - cognitoRaw !== undefined && - (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { + if (thirdPartyConfig.aws_cognito.enabled) { thirdParty.push({ provider: "cognito", - requiredField: str(cognitoRaw, "user_pool_id"), - cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + requiredField: thirdPartyConfig.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: thirdPartyConfig.aws_cognito.user_pool_region ?? "", }); } - const clerkRaw = asRecord(thirdPartyRaw?.["clerk"]); - if ( - clerkRaw !== undefined && - (yield* gate(clerkRaw, "enabled", "auth.third_party.clerk.enabled")) - ) { - thirdParty.push({ provider: "clerk", requiredField: str(clerkRaw, "domain") }); + if (thirdPartyConfig.clerk.enabled) { + thirdParty.push({ provider: "clerk", requiredField: thirdPartyConfig.clerk.domain ?? "" }); } - const workosRaw = asRecord(thirdPartyRaw?.["workos"]); - if ( - workosRaw !== undefined && - (yield* gate(workosRaw, "enabled", "auth.third_party.workos.enabled")) - ) { - thirdParty.push({ provider: "workos", requiredField: str(workosRaw, "issuer_url") }); + if (thirdPartyConfig.workos.enabled) { + thirdParty.push({ + provider: "workos", + requiredField: thirdPartyConfig.workos.issuer_url ?? "", + }); } authInput = { - siteUrl, + siteUrl: auth.site_url, captcha: captchaInput, passkey: passkeyInput, hooks, @@ -1790,55 +627,23 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // Computed after the auth block, before the shared validator call: `analyticsEnabled` - // can itself fail on a malformed bool, and that failure must report after an - // auth-block error for a config broken in both places. An absent `[analytics]` - // section defaults to enabled+postgres. - const analyticsString = ( - key: "backend" | "gcp_project_id" | "gcp_project_number" | "gcp_jwt_path", - envName: EnvOverrideName, - ): string => { - const fromEnv = remoteWins(`analytics.${key}`) ? undefined : envOverride(envName); - const raw = fromEnv ?? analyticsRaw?.[key]; - return typeof raw === "string" ? expandEnv(raw, lookup) : ""; - }; - const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - const analyticsEnabled = yield* resolveBoolOrFail( - "analytics.enabled", - analyticsRaw?.["enabled"], - true, - lookup, - remoteWins("analytics.enabled") ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), - ); - // Each GCP value is env-expanded, so an unresolved `env(VAR)` stays non-empty and - // passes the shared validator's `length === 0` check. - const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); - const gcpProjectNumber = analyticsString( - "gcp_project_number", - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - ); - const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); - - // Every check `validateResolvedConfig` owns runs through this single call (except - // `db.port`, checked earlier above). The sms/external checks below, which only this - // reader performs, run after this call succeeds, still gated on `authEnabled`. - const dbInput: DbInput = { port, majorVersion }; + const { analytics } = config; const analyticsInput: AnalyticsInput = { - enabled: analyticsEnabled, - backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, - gcpProjectId, - gcpProjectNumber, - gcpJwtPath, + enabled: analytics.enabled, + backend: nonEmpty(analytics.backend), + gcpProjectId: analytics.gcp_project_id ?? "", + gcpProjectNumber: analytics.gcp_project_number ?? "", + gcpJwtPath: analytics.gcp_jwt_path ?? "", }; const experimentalInput: ExperimentalInput = { webhooksPresent, webhooksEnabled, - pgdeltaFormatOptions: formatOptionsExpanded, + pgdeltaFormatOptions: pgDeltaConfig?.format_options ?? "", }; const validationInput: ConfigValidationInput = { - db: dbInput, - storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], - functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + db: { port, majorVersion }, + storageBucketNames: Object.keys(buckets), + functionSlugs: Object.keys(config.functions ?? {}), auth: authInput, edgeRuntimeDenoVersion: denoVersion, analytics: analyticsInput, @@ -1846,307 +651,34 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; yield* Effect.try({ try: () => validateResolvedConfig(validationInput), - catch: (cause) => - new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), + catch: (cause) => new DbConfigLoadError({ message: causeMessage(cause) }), }); if (authEnabled) { - // B4: sms — only this reader validates it; only the first enabled provider is checked. - const sms = asRecord(authRawResolved["sms"]); - if (sms !== undefined) { - const twilio = asRecord(sms["twilio"]); - const twilioVerify = asRecord(sms["twilio_verify"]); - const messagebird = asRecord(sms["messagebird"]); - const textlocal = asRecord(sms["textlocal"]); - const vonage = asRecord(sms["vonage"]); - const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); - const twilioVerifyEnabled = yield* gate( - twilioVerify, - "enabled", - "auth.sms.twilio_verify.enabled", - ); - const messagebirdEnabled = yield* gate( - messagebird, - "enabled", - "auth.sms.messagebird.enabled", - ); - const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); - const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); - if (twilioEnabled) { - if (str(twilio, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); - if (str(twilio, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio.message_service_sid", - ); - if (str(twilio, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); - } else if (twilioVerifyEnabled) { - if (str(twilioVerify, "account_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.account_sid", - ); - if (str(twilioVerify, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ); - if (str(twilioVerify, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); - } else if (messagebirdEnabled) { - if (str(messagebird, "originator").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); - if (str(messagebird, "access_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); - } else if (textlocalEnabled) { - if (str(textlocal, "sender").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); - if (str(textlocal, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); - } else if (vonageEnabled) { - if (str(vonage, "from").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.from"); - if (str(vonage, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); - if (str(vonage, "api_secret").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); - } - } - - // B5: external providers — only this reader validates it. linkedin/slack are - // deprecated and skipped. - const external = asRecord(authRawResolved["external"]); - if (external !== undefined) { - for (const name of Object.keys(external)) { - if (name === "linkedin" || name === "slack") continue; - const provider = asRecord(external[name]); - if (provider === undefined) continue; - if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; - if (str(provider, "client_id").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); - if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.secret`); - } - } - } - - // `[db.vault]` secret names, sorted (`setupInputsToken` sorts before hashing). - const vaultRaw = asRecord(db?.["vault"]); - const vaultNames = vaultRaw === undefined ? [] : Object.keys(vaultRaw).sort(); - - // `[db.migrations] enabled` — default true, overridable by - // `SUPABASE_DB_MIGRATIONS_ENABLED` unless the matched remote block explicitly set it. - const migrationsRaw = asRecord(db?.["migrations"]); - const migrationsEnabled = yield* resolveBoolOrFail( - "db.migrations.enabled", - migrationsRaw?.["enabled"], - true, - lookup, - remoteWins("db.migrations.enabled") ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), - ); - // `[db.seed]` — default enabled true, sql_paths `["seed.sql"]`; relative patterns are - // supabase-prefixed. Overridable by `SUPABASE_DB_SEED_ENABLED` unless a matched remote - // block supplied it at the override tier (set or forced false). - const seedRaw = asRecord(db?.["seed"]); - const seedEnabled = yield* resolveBoolOrFail( - "db.seed.enabled", - seedRaw?.["enabled"], - true, - lookup, - remoteWins("db.seed.enabled") ? undefined : envOverride("SUPABASE_DB_SEED_ENABLED"), - ); - // A string value (env override or TOML string) is env-expanded, then comma-split (no - // trimming; empty → `[]`). An array is decoded element-by-element: each element is - // expanded but not re-split, so `["env(SEEDS)"]` stays one pattern. The env override - // wins over the TOML value; absent/invalid falls back to the caller's default. - const splitGoSeedPaths = (value: string): ReadonlyArray => { - const expanded = expandEnv(value, lookup); - return expanded.length === 0 ? [] : expanded.split(","); - }; - /** - * Formats a float the way a weakly-decoded TOML `Glob` array element renders as a - * string: always fixed notation (never scientific), `+Inf`/`-Inf`/`NaN` spelled that - * way, and a negative zero keeps its sign — so the resolved pattern's hash key matches - * an existing recorded entry. - */ - const formatGoWeakFloat = (value: number): string => { - if (Number.isNaN(value)) return "NaN"; - if (value === Number.POSITIVE_INFINITY) return "+Inf"; - if (value === Number.NEGATIVE_INFINITY) return "-Inf"; - if (Object.is(value, -0)) return "-0"; - const str = value.toString(); - const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(str); - if (match === null) return str; - const [, sign = "", intPart = "", fracPart = "", expStr = "0"] = match; - const digits = intPart + fracPart; - const pointPos = intPart.length + Number(expStr); - if (pointPos <= 0) return `${sign}0.${"0".repeat(-pointPos)}${digits}`; - if (pointPos >= digits.length) return `${sign}${digits}${"0".repeat(pointPos - digits.length)}`; - return `${sign}${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`; - }; - // A non-string array element isn't dropped: a bool coerces to `"1"`/`"0"` and a - // number to its decimal string, then flows through the same env-expand/resolve - // pipeline as a real string entry. - const weakCoerceGlobEntry = (value: unknown): string | undefined => { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "1" : "0"; - if (typeof value === "number") return formatGoWeakFloat(value); - return undefined; - }; - // A non-scalar glob element (nested array/table, or a bare TOML datetime) fails with - // an "unconvertible type" error instead of being silently dropped; each datetime - // variant reports its own type name for the message. `smol-toml` parses every TOML - // datetime to a `TomlDate` (a `Date` subclass), exposing the `isDate`/`isTime`/ - // `isLocal` discriminators needed to pick the right variant name. - const goTomlDateType = (value: SmolToml.TomlDate): string => { - if (value.isDate()) return "toml.LocalDate"; - if (value.isTime()) return "toml.LocalTime"; - return value.isLocal() ? "toml.LocalDateTime" : "time.Time"; - }; - const goUnconvertibleType = (value: unknown): string | undefined => - value instanceof SmolToml.TomlDate - ? goTomlDateType(value) - : Array.isArray(value) - ? "[]interface {}" - : typeof value === "object" && value !== null - ? "map[string]interface {}" - : undefined; - // Returns the "unconvertible type" issue for each bad array element, without - // failing — both `Glob` fields' issues are combined into one error afterward (see - // `failOnGlobIssues`), with `db.migrations.schema_paths` ordered before `db.seed.sql_paths`. - const globArrayIssues = ( - keyPath: string, - values: ReadonlyArray, - ): ReadonlyArray => - values.flatMap((value, index) => { - const goType = goUnconvertibleType(value); - return goType === undefined - ? [] - : [`'${keyPath}[${index}]' expected type 'string', got unconvertible type '${goType}'`]; - }); - // Fails once with every issue collected across both `Glob` fields, instead of - // failing on the first field checked. - const failOnGlobIssues = ( - issues: ReadonlyArray, - ): Effect.Effect => - issues.length === 0 - ? Effect.void - : fail( - `failed to parse config: decoding failed due to the following error(s):\n\n${issues.join("\n")}`, + yield* Effect.try({ + try: () => { + validateAuthSmsProviders(auth.sms); + validateAuthExternalProviders( + resolveAuthExternalProviders(asRecord(declaredDocument["auth"]), auth.external), ); - // A scalar top-level value (e.g. `schema_paths = 42`) is treated like a - // single-element array: a zero-length map decodes to `[]`, anything else weakly - // coerces or reports an unconvertible-type issue. A `TomlDate` must not match the - // zero-length-map case — its value is stored internally, so `Object.keys` is empty - // too, but it should still be treated as unconvertible. - const resolveScalarGlobFallback = ( - keyPath: string, - value: unknown, - ): { readonly resolved: ReadonlyArray; readonly issues: ReadonlyArray } => { - if ( - typeof value === "object" && - value !== null && - !(value instanceof SmolToml.TomlDate) && - Object.keys(value).length === 0 - ) { - return { resolved: [], issues: [] }; - } - const coerced = weakCoerceGlobEntry(value); - if (coerced !== undefined) { - return { resolved: [coerced], issues: [] }; - } - return { resolved: [], issues: globArrayIssues(keyPath, [value]) }; - }; - /** - * Resolves one `Glob`-typed field into its pre-supabase-join patterns: env override, - * real array, bare string, absent key (caller's default), or the scalar fallback - * above. Returns issues instead of failing — see {@link failOnGlobIssues}. - */ - const resolveGlobField = ( - keyPath: string, - raw: unknown, - override: string | undefined, - absentDefault: ReadonlyArray, - ): { readonly patterns: ReadonlyArray; readonly issues: ReadonlyArray } => { - if (override !== undefined) { - return { patterns: splitGoSeedPaths(override), issues: [] }; - } - if (Array.isArray(raw)) { - return { - patterns: raw - .map((pattern) => weakCoerceGlobEntry(pattern)) - .filter((pattern): pattern is string => pattern !== undefined) - .map((pattern) => expandEnv(pattern, lookup)), - issues: globArrayIssues(keyPath, raw), - }; - } - if (typeof raw === "string") { - return { patterns: splitGoSeedPaths(raw), issues: [] }; - } - if (raw === undefined) { - return { patterns: absentDefault, issues: [] }; - } - const fallback = resolveScalarGlobFallback(keyPath, raw); - return { - patterns: fallback.resolved.map((pattern) => expandEnv(pattern, lookup)), - issues: fallback.issues, - }; - }; - const rawSqlPaths = seedRaw?.["sql_paths"]; - const sqlPathsOverride = remoteWins("db.seed.sql_paths") - ? undefined - : envOverride("SUPABASE_DB_SEED_SQL_PATHS"); - const sqlPathsResolved = resolveGlobField("db.seed.sql_paths", rawSqlPaths, sqlPathsOverride, [ - "seed.sql", - ]); - // Patterns are already env-expanded above (expansion runs before the split); resolve - // each to its config-load form (absolute verbatim, relative supabase-joined). - const seedSqlPaths = sqlPathsResolved.patterns.map((pattern) => - resolveSeedSqlPath(path, pattern), - ); - - // `[db.migrations] schema_paths` — default `[]`, resolved the same way as - // `[db.seed].sql_paths` above, but unconditionally (not gated on `db.migrations.enabled`). - const rawSchemaPaths = migrationsRaw?.["schema_paths"]; - const schemaPathsOverride = remoteOverrideKeys.has("db.migrations.schema_paths") - ? undefined - : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"); - const schemaPathsResolved = resolveGlobField( - "db.migrations.schema_paths", - rawSchemaPaths, - schemaPathsOverride, - [], - ); - - // Combines both `Glob` fields' issues before failing once, so a config invalid in - // both surfaces both instead of only the first field checked. - yield* failOnGlobIssues([...schemaPathsResolved.issues, ...sqlPathsResolved.issues]); - - const schemaPaths = schemaPathsResolved.patterns.map((pattern) => - resolveSeedSqlPath(path, pattern), - ); + }, + catch: (cause) => new DbConfigLoadError({ message: causeMessage(cause) }), + }); + } - // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` - // ciphertext. `resolved` is true only after a successful decrypt-or-passthrough. An - // `encrypted:` value that cannot be decrypted aborts the command, never silently skipped. + const vaultConfig = config.db.vault ?? {}; + const vaultNames = Object.keys(vaultConfig).sort(); const vault: Array = []; - if (resolveVaultSecrets && vaultRaw !== undefined) { - for (const name of Object.keys(vaultRaw).sort()) { - const raw = vaultRaw[name]; - const value = typeof raw === "string" ? expandEnv(raw, lookup) : ""; - // Empty or an unexpanded `env(...)` reference is left unresolved rather than hashed. + if (resolveVaultSecrets) { + for (const name of vaultNames) { + const value = vaultConfig[name] ?? ""; if (value.length === 0 || ENV_PATTERN.test(value)) { vault.push({ name, value, resolved: false }); continue; } if (isEncryptedSecret(value)) { - const decrypted = decryptSecret(value, dotenvPrivateKeys); - if (!decrypted.ok) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: ${decrypted.error}`, - }); - } + const decrypted = decryptSecret(value, resolvedConfig.dotenvPrivateKeys); + if (!decrypted.ok) return yield* fail(`failed to parse config: ${decrypted.error}`); vault.push({ name, value: decrypted.value, resolved: true }); continue; } @@ -2154,70 +686,45 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // `[api] auto_expose_new_tables` is tri-state: present → `Some(bool)`, absent → - // `None` (never false). A malformed override/value aborts the load rather than - // silently coercing to false. - const apiAutoExposeNewTables = yield* resolveOptionalBoolOrFail( - "api.auto_expose_new_tables", - remoteWins("api.auto_expose_new_tables") - ? undefined - : envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"), - apiRaw?.["auto_expose_new_tables"], - lookup, - ); - const apiSchemas = resolveStringSlice( - (remoteWins("api.schemas") ? undefined : envOverride("SUPABASE_API_SCHEMAS")) ?? - apiRaw?.["schemas"], - DEFAULT_API_SCHEMAS, - lookup, - ); - if (apiSchemas === undefined) { - return yield* new DbConfigLoadError({ - message: "failed to parse config: invalid api.schemas.", - }); - } + const schemaPaths = yield* getKey(CliConfigKeys.db.migrations.schemaPaths); + const seedEnabled = config.db.seed.enabled; + const seedSqlPaths = (yield* getKey(CliConfigKeys.db.seed.sqlPaths)).value; + + const apiSchemas = config.api.schemas; + const exposedSchemas = config.api.enabled + ? [...new Set(apiSchemas.map((schema) => schema.trim()).filter((schema) => schema.length > 0))] + : []; const values: DbTomlValues = { projectEnv, - envLookup: envOverride, apiSchemas, + exposedSchemas, port, shadowPort, - password: passwordRaw !== undefined ? expandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, + password: (yield* getKey(CliConfigKeys.db.password)).value, projectId, majorVersion, orioledbVersion, denoVersion, pgDelta: { - enabled, + enabled: (yield* getKey(CliConfigKeys.experimental.pgdelta.enabled)).value, declarativeSchemaPath, formatOptions, }, webhooksEnabled, baseline: { authEnabled, - storageEnabled: yield* resolveBoolOrFail( - "storage.enabled", - storageRaw?.["enabled"], - true, - lookup, - ), - realtimeEnabled: yield* resolveBoolOrFail( - "realtime.enabled", - realtimeRaw?.["enabled"], - true, - lookup, - ), - apiAutoExposeNewTables, + storageEnabled: config.storage.enabled, + realtimeEnabled: config.realtime.enabled, + apiAutoExposeNewTables: Option.fromNullishOr(config.api.auto_expose_new_tables), vaultNames, }, - migrationsEnabled, - schemaPaths, - schemaPathPatterns: schemaPathsResolved.patterns, + migrationsEnabled: config.db.migrations.enabled, + schemaPaths: schemaPaths.value, + schemaPathPatterns: schemaPaths.unnormalized ?? schemaPaths.value, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, - appliedRemote, - remoteOverrideKeys, + appliedRemote: Option.getOrUndefined(resolvedConfig.appliedRemote), }; return values; }); @@ -2275,9 +782,12 @@ export const readDbToml = ( ? readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets).pipe( // Fall back to the ignore-file defaults path (never re-reads the broken config) // so a best-effort caller gets a well-formed defaults result instead of a throw. - Effect.catchTag("DbConfigLoadError", () => - readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), - ), + Effect.catchTags({ + DbConfigLoadError: () => + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), + CliConfigValueError: () => + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), + }), ) : readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets); }; diff --git a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts index 7c15a71d5c..59dbec46a3 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts @@ -1,21 +1,38 @@ import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Config, ConfigProvider, Effect, Exit, FileSystem, Option, Path, Ref } from "effect"; +import { + Cause, + Config, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Ref, +} from "effect"; import type { PlatformError } from "effect/PlatformError"; -import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { checkDbToml, - loadProjectEnv, + loadProjectEnvValues, readDbToml, resolveDeclarativeDir, - resolveSeedSqlPath, + type DbTomlValues, } from "./db-config.toml-read.ts"; +import { resolveSeedSqlPath } from "../shared/config/seed-path.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; +import type { CliConfigValues } from "../config/cli-config-values.service.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, } from "../telemetry/command-telemetry-attributes.ts"; +const servicesLive = Layer.mergeAll(BunServices.layer, cliConfigValuesTestLayer); + function withConfig(content: string | undefined) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -43,43 +60,42 @@ const makeDir = (dir: string, segments: ReadonlyArray) => yield* fs.makeDirectory(path.join(dir, ...segments), { recursive: true }); }).pipe(Effect.provide(BunServices.layer)); -const withEnv = (values: Readonly>) => - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), - ); +const withEnv = + (values: Readonly>) => + (body: Effect.Effect) => + withConfigEnv(values, body); const read = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); const readRef = (workdir: string, ref: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir, ref); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); const loadEnv = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* loadProjectEnv(fs, path, workdir); + return yield* loadProjectEnvValues(fs, path, workdir); }).pipe(Effect.provide(BunServices.layer)); describe("read (lenient) vs check (throws) split", () => { const withServices = ( dir: string, - run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* run(fs, path); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); it.effect("checkDbToml throws on an undecryptable secret", () => { return withConfig('[db]\nroot_key = "encrypted:anything"\n').pipe( @@ -98,6 +114,20 @@ describe("read (lenient) vs check (throws) split", () => { ); }); + it.effect("checkDbToml surfaces an invalid env value as CliConfigValueError", () => { + return withConfig("[db]\nport = 5432\n").pipe( + Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PORT=notaport\n")), + Effect.flatMap((dir) => withServices(dir, (fs, path) => checkDbToml(fs, path, dir))), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error._tag).toBe("CliConfigValueError"); + expect(error.message).toContain('Invalid SUPABASE_DB_PORT="notaport"'); + }), + ), + ); + }); + it.effect("can skip vault resolution without skipping the rest of config validation", () => { return withConfig( [ @@ -145,7 +175,7 @@ describe("read (lenient) vs check (throws) split", () => { ), Effect.tap((v) => Effect.sync(() => { - expect(v.projectId).toEqual(Option.some("lenientproj")); + expect(v.projectId).toBe("lenientproj"); }), ), ); @@ -154,18 +184,16 @@ describe("read (lenient) vs check (throws) split", () => { describe("readDbToml", () => { it.effect("returns defaults when config.toml is absent", () => { - return withConfig(undefined).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.port).toBe(54322); - expect(v.shadowPort).toBe(54320); - expect(v.password).toBe("postgres"); - expect(Option.isNone(v.projectId)).toBe(true); - expect(v.denoVersion).toBe(2); - }), - ), - ); + return Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* withConfig(undefined); + const v = yield* read(dir); + expect(v.port).toBe(54322); + expect(v.shadowPort).toBe(54320); + expect(v.password).toBe("postgres"); + expect(v.projectId).toBe(sanitizeProjectId(path.basename(dir))); + expect(v.denoVersion).toBe(2); + }).pipe(Effect.provide(BunServices.layer)); }); // A known-good test vector: decrypts to "value" under the keypair below. @@ -174,44 +202,35 @@ describe("readDbToml", () => { "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; it.effect("decrypts an encrypted: [db.vault] secret when DOTENV_PRIVATE_KEY is set", () => { - return withEnvVar( - "DOTENV_PRIVATE_KEY", - VAULT_PRIVATE_KEY, - withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); - }), - ), - withEnv({}), + return withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); + }), ), + withEnv({ DOTENV_PRIVATE_KEY: VAULT_PRIVATE_KEY }), ); }); it.effect("fails the load for an encrypted: [db.vault] secret with no private key", () => { - return withEnvVar( - "DOTENV_PRIVATE_KEY", - undefined, - withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: missing private key", - ); - } - }), - ), - withEnv({}), + return withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "failed to parse config: missing private key", + ); + } + }), ), ); }); - it.effect("collapses. and .. in relative seed sql_paths like Go's path.Join", () => { + it.effect("collapses. and .. in relative seed sql_paths", () => { return withConfig( ["[db.seed]", 'sql_paths = ["../seed.sql", "sub/../other.sql", "./plain.sql"]', ""].join( "\n", @@ -249,7 +268,7 @@ describe("readDbToml", () => { ); }); - it.effect("treats an empty-string db.seed.sql_paths as no patterns (Go []string{})", () => { + it.effect("treats an empty-string db.seed.sql_paths as no patterns", () => { return withConfig(["[db.seed]", 'sql_paths = ""', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -275,7 +294,7 @@ describe("readDbToml", () => { }, ); - it.effect("expands an env() array element but does NOT split it (Go array asymmetry)", () => { + it.effect("expands an env() array element but does NOT split it", () => { return withConfig(["[db.seed]", 'sql_paths = ["env(SEEDS)"]', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -287,20 +306,6 @@ describe("readDbToml", () => { ); }); - it.effect( - "weakly coerces non-string db.seed.sql_paths array elements (Go mapstructure parity)", - () => { - return withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.seed.sqlPaths).toEqual(["supabase/42", "supabase/1", "supabase/seed.sql"]); - }), - ), - ); - }, - ); - it.effect( "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", () => { @@ -331,7 +336,7 @@ describe("readDbToml", () => { ); it.effect( - "on Windows, resolves a leading-slash schema/seed path pattern under supabase/ instead of treating it as absolute (Go filepath.IsAbs parity)", + "on Windows, resolves a leading-slash schema/seed path pattern under supabase/ instead of treating it as absolute", () => { // A bare leading `/` has no Windows volume name, so it resolves as relative and joins to // `supabase/`, unlike Node's `path.win32.isAbsolute`, which treats a leading separator as @@ -355,96 +360,82 @@ describe("readDbToml", () => { }, ); - it.effect( - "weakly coerces non-string db.migrations.schema_paths array elements (Go mapstructure parity)", - () => { - // `v.UnmarshalExact` never sets `WeaklyTypedInput: false`, so viper's - // `defaultDecoderConfig` default of `true` stands — mapstructure's `decodeString` - // coerces a bool to "1"/"0" and a number to its decimal string rather than - // erroring or dropping the element. Verified empirically: - // `schema_paths = [42, true, "schemas/*.sql"]` resolves to - // `supabase/{42,1,schemas/*.sql}`, not a filtered two-element list. - return withConfig( - ["[db.migrations]", 'schema_paths = [42, true, "schemas/*.sql"]', ""].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/42", "supabase/1", "supabase/schemas/*.sql"]); - }), - ), - ); + it.effect.each([ + { + name: "seed array with number and bool", + table: "db.seed", + field: "sql_paths", + value: '[42, true, "seed.sql"]', + expected: ["supabase/42", "supabase/1", "supabase/seed.sql"], }, - ); - - it.effect( - "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation (Go strconv.FormatFloat parity)", - () => { - return withConfig(["[db.migrations]", "schema_paths = [1e21]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/1000000000000000000000"]); - }), - ), - ); + { + name: "schema array with number and bool", + table: "db.migrations", + field: "schema_paths", + value: '[42, true, "schemas/*.sql"]', + expected: ["supabase/42", "supabase/1", "supabase/schemas/*.sql"], }, - ); - - it.effect( - "formats TOML special-float db.migrations.schema_paths entries like Go's strconv.FormatFloat, not JS's toString (Go parity)", - () => { - return withConfig(["[db.migrations]", "schema_paths = [inf, -inf, nan]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/+Inf", "supabase/-Inf", "supabase/NaN"]); - }), - ), - ); + { + name: "large number as fixed decimal", + table: "db.migrations", + field: "schema_paths", + value: "[1e21]", + expected: ["supabase/1000000000000000000000"], }, - ); - - it.effect( - "weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths (Go mapstructure weak-decode of a []string field)", - () => { - return Effect.all([ - withConfig(["[db.migrations]", "schema_paths = 42", ""].join("\n")), - withConfig(["[db.migrations]", "schema_paths = true", ""].join("\n")), - ]).pipe( - Effect.flatMap(([dirNumber, dirBool]) => Effect.all([read(dirNumber), read(dirBool)])), - Effect.tap(([numberResult, boolResult]) => - Effect.sync(() => { - expect(numberResult.schemaPaths).toEqual(["supabase/42"]); - expect(boolResult.schemaPaths).toEqual(["supabase/1"]); - }), - ), - ); + { + name: "special floats", + table: "db.migrations", + field: "schema_paths", + value: "[inf, -inf, nan]", + expected: ["supabase/+Inf", "supabase/-Inf", "supabase/NaN"], }, - ); - - it.effect( - "treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns (Go mapstructure zero-length-map special case)", - () => { - return withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual([]); - }), - ), - ); + { + name: "scalar number schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "42", + expected: ["supabase/42"], }, - ); + { + name: "scalar bool schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "true", + expected: ["supabase/1"], + }, + { + name: "empty table schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "{}", + expected: [], + }, + { + name: "scalar number sql_paths", + table: "db.seed", + field: "sql_paths", + value: "42", + expected: ["supabase/42"], + }, + ])("weakly coerces a glob list: $name", ({ table, field, value, expected }) => { + return withConfig([`[${table}]`, `${field} = ${value}`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(table === "db.seed" ? v.seed.sqlPaths : v.schemaPaths).toEqual(expected); + }), + ), + ); + }); it.effect.each([ - { name: "offset date-time", literal: "1979-05-27T07:32:00Z", goType: "time.Time" }, - { name: "local date-time", literal: "1979-05-27T07:32:00", goType: "toml.LocalDateTime" }, - { name: "local date", literal: "1979-05-27", goType: "toml.LocalDate" }, - { name: "local time", literal: "07:32:00", goType: "toml.LocalTime" }, + { name: "offset date-time", literal: "1979-05-27T07:32:00Z", typeName: "offset date-time" }, + { name: "local date-time", literal: "1979-05-27T07:32:00", typeName: "local date-time" }, + { name: "local date", literal: "1979-05-27", typeName: "local date" }, + { name: "local time", literal: "07:32:00", typeName: "local time" }, ])( - "aborts the whole config load on a TOP-LEVEL bare $name db.migrations.schema_paths instead of silently treating it as empty (Go mapstructure UnconvertibleTypeError, review CLI-1958)", - ({ literal, goType }) => { + "rejects a bare $name db.migrations.schema_paths instead of treating it as empty", + ({ literal, typeName }) => { // `smol-toml` parses every TOML datetime variant to a `TomlDate` (a `Date` subclass) // that stores its value internally, not as an enumerable own property, so // `Object.keys(tomlDate).length === 0` — same as a genuine empty inline table @@ -459,7 +450,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - `'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '${goType}'`, + `db.migrations.schema_paths[0]: expected a string, got ${typeName}`, ); } }), @@ -468,244 +459,218 @@ describe("readDbToml", () => { }, ); - it.effect( - "aborts the whole config load on a bare datetime db.migrations.schema_paths ARRAY element (Go mapstructure UnconvertibleTypeError, review CLI-1958)", - () => { - // Same `TomlDate`-vs-generic-object collision as the top-level scalar case above, but - // reached through the real-array branch instead of the scalar fallback: the valid glob - // entry must never mask the datetime's failure. - return withConfig( - ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""].join( - "\n", - ), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'time.Time'", - ); - } - }), - ), - ); + it.effect.each([ + { + name: "a datetime schema_paths array element", + toml: ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""], + issue: "db.migrations.schema_paths[1]: expected a string, got offset date-time", }, - ); - - it.effect( - "aborts the whole config load on a TOP-LEVEL bare datetime db.seed.sql_paths (same UnmarshalExact call as schema_paths, review CLI-1958)", - () => { - return withConfig(["[db.seed]", "sql_paths = 1979-05-27T07:32:00Z", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type 'time.Time'", - ); - } - }), - ), - ); + { + name: "a bare datetime sql_paths", + toml: ["[db.seed]", "sql_paths = 1979-05-27T07:32:00Z", ""], + issue: "db.seed.sql_paths[0]: expected a string, got offset date-time", }, - ); - - it.effect( - "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (Go mapstructure UnconvertibleTypeError, synthetic index 0)", - () => { - // A non-empty map isn't weakly coercible, so it fails decoding element 0 the same way a - // nested-array/table array element does. - return withConfig(["[db.migrations.schema_paths]", 'foo = "bar"', ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type 'map[string]interface {}'", - ); - } - }), - ), - ); + { + name: "a table schema_paths", + toml: ["[db.migrations.schema_paths]", 'foo = "bar"', ""], + issue: "db.migrations.schema_paths[0]: expected a string, got table", }, - ); - - it.effect( - "weakly coerces a TOP-LEVEL scalar db.seed.sql_paths instead of falling back to the ['seed.sql'] default", - () => { - // The absent-key default (`["seed.sql"]`) only applies when the key is missing entirely; - // a present scalar still goes through the weak-decode wrap, same as schema_paths above. - return withConfig(["[db.seed]", "enabled = true", "sql_paths = 42", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.seed.sqlPaths).toEqual(["supabase/42"]); - }), - ), - ); + { + name: "a nested-list schema_paths element", + toml: ["[db.migrations]", "schema_paths = [[]]", ""], + issue: "failed to parse config:\ndb.migrations.schema_paths[0]: expected a string, got array", }, - ); - - it.effect( - "aborts the whole config load on a non-scalar db.migrations.schema_paths element (Go mapstructure UnconvertibleTypeError)", - () => { - // Unlike a bool/number (weakly coerced above), a nested array/table fails the whole - // config load rather than dropping just that element. - return withConfig(["[db.migrations]", "schema_paths = [[]]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", - ); - } - }), - ), - ); + { + name: "a table schema_paths element", + toml: ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""], + issue: "db.migrations.schema_paths[1]: expected a string, got table", }, - ); - - it.effect( - "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index (Go mapstructure parity)", - () => { - return withConfig( - ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'map[string]interface {}'", - ); - } - }), - ), - ); + { + name: "a nested-list sql_paths element", + toml: ["[db.seed]", "sql_paths = [[]]", ""], + issue: "db.seed.sql_paths[0]: expected a string, got array", }, - ); + ])("rejects $name", ({ toml, issue }) => { + return withConfig(toml.join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(issue); + }), + ), + ); + }); - it.effect( - "aborts the whole config load on a non-scalar db.seed.sql_paths element (same UnmarshalExact call as schema_paths)", - () => { - return withConfig(["[db.seed]", "sql_paths = [[]]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", - ); - } - }), - ), - ); - }, - ); + it.effect("reports invalid entries of both glob lists in one error, schema_paths first", () => { + return withConfig( + ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( + "\n", + ), + ).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = Cause.pretty(exit.cause); + const schemaIssue = "db.migrations.schema_paths[0]: expected a string, got array"; + const seedIssue = "db.seed.sql_paths[0]: expected a string, got array"; + expect(message).toContain(schemaIssue); + expect(message).toContain(seedIssue); + expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); + } + }), + ), + ); + }); - it.effect( - "aggregates unconvertible-entry issues from BOTH db.seed.sql_paths and db.migrations.schema_paths in one error (Go UnmarshalExact single-pass parity, review CLI-1958)", - () => { - return withConfig( - ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( - "\n", - ), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const message = Cause.pretty(exit.cause); - const schemaIssue = - "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; - const seedIssue = - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; - expect(message).toContain(schemaIssue); - expect(message).toContain(seedIssue); - expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); - } - }), - ), - ); + it.effect.each([ + { + name: "numeric db.seed.enabled = 0", + table: "db.seed", + field: "enabled", + value: "0", + read: (v: DbTomlValues) => v.seed.enabled, + expected: false, }, - ); + { + name: "numeric db.migrations.enabled = 0", + table: "db.migrations", + field: "enabled", + value: "0", + read: (v: DbTomlValues) => v.migrationsEnabled, + expected: false, + }, + { + name: "numeric experimental.pgdelta.enabled = 1", + table: "experimental.pgdelta", + field: "enabled", + value: "1", + read: (v: DbTomlValues) => v.pgDelta.enabled, + expected: true, + }, + { + name: 'string auth.enabled = "0"', + table: "auth", + field: "enabled", + value: '"0"', + read: (v: DbTomlValues) => v.baseline.authEnabled, + expected: false, + }, + { + name: 'upper-case api.auto_expose_new_tables = "TRUE"', + table: "api", + field: "auto_expose_new_tables", + value: '"TRUE"', + read: (v: DbTomlValues) => Option.getOrNull(v.baseline.apiAutoExposeNewTables), + expected: true, + }, + ])("decodes $name as a bool", ({ table, field, value, read: pick, expected }) => { + return withConfig([`[${table}]`, `${field} = ${value}`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(pick(v)).toBe(expected); + }), + ), + ); + }); - it.effect( - "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", - () => { - const ref = "schmschmschmschmschm"; - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - 'db.migrations.schema_paths = ["remote-only.sql"]', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), + it.effect.each([ + { + name: "db.seed.enabled", + table: "db.seed", + field: "enabled", + read: (v: DbTomlValues) => v.seed.enabled, + }, + { + name: "db.migrations.enabled", + table: "db.migrations", + field: "enabled", + read: (v: DbTomlValues) => v.migrationsEnabled, + }, + { + name: "experimental.pgdelta.enabled", + table: "experimental.pgdelta", + field: "enabled", + read: (v: DbTomlValues) => v.pgDelta.enabled, + }, + { + name: "api.auto_expose_new_tables", + table: "api", + field: "auto_expose_new_tables", + read: (v: DbTomlValues) => Option.getOrNull(v.baseline.apiAutoExposeNewTables), + }, + ])( + "expands an env() reference in $name through the shell and project .env", + ({ table, field, read: pick }) => { + return withConfig([`[${table}]`, `${field} = "env(WEAK_FLAG)"`, ""].join("\n")).pipe( + Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/remote-only.sql"]); + expect(pick(v)).toBe(true); }), ), - withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-only.sql" }), + withEnv({ WEAK_FLAG: "TRUE" }), ); }, ); - it.effect("decodes a numeric db.seed.enabled = 0 as false (Go weak-bool decode)", () => { - return withConfig(["[db.seed]", "enabled = 0", ""].join("\n")).pipe( + it.effect("fails on an unresolved env() reference in a bool field", () => { + return withConfig(["[db.seed]", 'enabled = "env(WEAK_FLAG_UNSET)"', ""].join("\n")).pipe( Effect.flatMap(read), - Effect.tap((v) => + Effect.exit, + Effect.tap((exit) => Effect.sync(() => { - expect(v.seed.enabled).toBe(false); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Invalid db.seed.enabled in supabase/config.toml", + ); + } }), ), ); }); - it.effect("decodes a numeric db.migrations.enabled = 0 as false", () => { - return withConfig(["[db.migrations]", "enabled = 0", ""].join("\n")).pipe( + it.effect("decodes a numeric string db.port", () => { + return withConfig(["[db]", 'port = "6002"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.migrationsEnabled).toBe(false); + expect(v.port).toBe(6002); }), ), ); }); it.effect( - "decodes a numeric experimental.pgdelta.enabled = 1 as true (Go weak-bool decode)", + "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS beats a matched remote's db.migrations.schema_paths", () => { - return withConfig(["[experimental.pgdelta]", "enabled = 1", ""].join("\n")).pipe( - Effect.flatMap(read), + const ref = "schmschmschmschmschm"; + return withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + 'db.migrations.schema_paths = ["remote-only.sql"]', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); + expect(v.schemaPaths).toEqual(["supabase/env-only.sql"]); }), ), + withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-only.sql" }), ); }, ); - it.effect("rejects an explicit db.port = 0 (Go's Missing required field)", () => { + it.effect("rejects an explicit db.port = 0 as a missing required field", () => { return withConfig(["[db]", "port = 0", ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -720,9 +685,7 @@ describe("readDbToml", () => { ); }); - it.effect("an explicit remote db.migrations.enabled beats SUPABASE_DB_MIGRATIONS_ENABLED", () => { - // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv, - // so an explicit remote value wins over the env var. + it.effect("SUPABASE_DB_MIGRATIONS_ENABLED beats a matched remote's db.migrations.enabled", () => { const ref = "abcdefghijklmnopqrst"; return withConfig( ["[remotes.prod]", `project_id = "${ref}"`, "db.migrations.enabled = true", ""].join("\n"), @@ -730,7 +693,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.migrationsEnabled).toBe(true); + expect(v.migrationsEnabled).toBe(false); }), ), withEnv({ SUPABASE_DB_MIGRATIONS_ENABLED: "false" }), @@ -750,7 +713,7 @@ describe("readDbToml", () => { ); }); - it.effect("collapses. and .. in relative db.migrations.schema_paths like Go's path.Join", () => { + it.effect("collapses. and .. in relative db.migrations.schema_paths", () => { return withConfig( [ "[db.migrations]", @@ -786,7 +749,7 @@ describe("readDbToml", () => { }, ); - it.effect("defaults db.migrations.schema_paths to [] when absent (Go's Glob zero value)", () => { + it.effect("defaults db.migrations.schema_paths to [] when absent", () => { return withConfig(["[db]", "port = 54322", ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -798,9 +761,8 @@ describe("readDbToml", () => { }); it.effect( - "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", + "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS beats a matched remote's db.migrations.schema_paths in a [remotes.*.db.migrations] table", () => { - // Same override-tier precedence as db.migrations.enabled above. const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -814,7 +776,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/remote-wins.sql"]); + expect(v.schemaPaths).toEqual(["supabase/env-wins.sql"]); }), ), withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-wins.sql" }), @@ -822,26 +784,29 @@ describe("readDbToml", () => { }, ); - it.effect("an explicit remote experimental.pgdelta.enabled beats its SUPABASE_* env var", () => { - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); - }), - ), - withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }), - ); - }); + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED beats a matched remote's pgdelta.enabled", + () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.pgDelta.enabled).toBe(false); + }), + ), + withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }), + ); + }, + ); it.effect("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED still wins when the block omits pgdelta", () => { const ref = "abcdefghijklmnopqrst"; @@ -849,14 +814,14 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); + expect(v.pgDelta.enabled).toBe(false); }), ), - withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "true" }), + withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }), ); }); - it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { + it.effect("SUPABASE_AUTH_ENABLED beats a matched remote's auth.enabled", () => { const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -870,7 +835,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.baseline.authEnabled).toBe(false); + expect(v.baseline.authEnabled).toBe(true); }), ), withEnv({ SUPABASE_AUTH_ENABLED: "true" }), @@ -891,11 +856,8 @@ describe("readDbToml", () => { }); it.effect( - "an explicit remote experimental.webhooks.enabled beats its SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED env var", + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED=false beats a matched remote's enabled = true and fails validation", () => { - // Without this precedence, the suppressed env value would win, and the merged - // [experimental.webhooks] section (present via the remote block) would then fail - // validation ("Webhooks cannot be deactivated"). const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -910,8 +872,10 @@ describe("readDbToml", () => { Effect.exit, Effect.tap((exit) => Effect.sync(() => { - expect(Exit.isSuccess(exit)).toBe(true); - if (Exit.isSuccess(exit)) expect(exit.value.webhooksEnabled).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("Webhooks cannot be deactivated"); + } }), ), withEnv({ SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "false" }), @@ -999,14 +963,13 @@ describe("readDbToml", () => { ); }); - it.effect("a remote block forcing db.seed.enabled=false beats SUPABASE_DB_SEED_ENABLED", () => { - // A remote block that omits db.seed.enabled stays unseeded even with the env var set. + it.effect("SUPABASE_DB_SEED_ENABLED beats the seed default a matched remote implies", () => { const ref = "abcdefghijklmnopqrst"; return withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.seed.enabled).toBe(false); + expect(v.seed.enabled).toBe(true); }), ), withEnv({ SUPABASE_DB_SEED_ENABLED: "true" }), @@ -1196,7 +1159,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects deno_version = 0 with Go's missing-required message", () => { + it.effect("rejects deno_version = 0 with the missing-required message", () => { return withConfig(["[edge_runtime]", "deno_version = 0", ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -1296,7 +1259,7 @@ describe("readDbToml", () => { ); }); - it.effect("accepts an underscore bucket name like Go's permissive pattern", () => { + it.effect("accepts an underscore bucket name", () => { // The bucket-name pattern uses `\w` (includes `_`) and is not case-restricted despite the // prose, so `Bad_Name` actually passes: match the regex, not the message text. return withConfig("[storage.buckets.Bad_Name]\n").pipe(Effect.flatMap(read)); @@ -1331,18 +1294,6 @@ describe("readDbToml", () => { ); }); - it.effect("parses [api] auto_expose_new_tables string with Go bool tokens (TRUE → true)", () => { - // `TRUE`/`1`/`t` are also accepted as true, not just lowercase `true`. - return withConfig('[api]\nauto_expose_new_tables = "TRUE"\n').pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.baseline.apiAutoExposeNewTables)).toBe(true); - }), - ), - ); - }); - it.effect("decodes empty api schemas while keeping auto_expose_new_tables absent", () => { return withConfig('[api]\nschemas = ""\n').pipe( Effect.flatMap(read), @@ -1364,9 +1315,9 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("DbConfigLoadError"); + expect(causeText).toContain("CliConfigValueError"); expect(causeText).toContain( - "failed to parse config: invalid api.auto_expose_new_tables.", + "Invalid api.auto_expose_new_tables in supabase/config.toml", ); } }), @@ -1418,7 +1369,7 @@ describe("readDbToml", () => { ); }); - it.effect("treats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=1 as true (Go strconv.ParseBool)", () => { + it.effect("treats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=1 as true", () => { return withConfig(undefined).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -1430,7 +1381,7 @@ describe("readDbToml", () => { ); }); - it.effect("fails on a malformed SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED (Go config error)", () => { + it.effect("fails on a malformed SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", () => { return withConfig(undefined).pipe( Effect.flatMap(read), Effect.exit, @@ -1439,7 +1390,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: invalid experimental.pgdelta.enabled: maybe.", + "Invalid SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", ); } }), @@ -1448,20 +1399,21 @@ describe("readDbToml", () => { ); }); - it.effect("parses [auth] enabled string forms via Go ParseBool and fails on malformed", () => { - return Effect.gen(function* () { - const ok = yield* withConfig(["[auth]", 'enabled = "0"', ""].join("\n")); - const bad = yield* withConfig(["[storage]", 'enabled = "nope"', ""].join("\n")); - const v = yield* read(ok); - expect(v.baseline.authEnabled).toBe(false); // "0" → false (ParseBool) - const exit = yield* read(bad).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: invalid storage.enabled.", - ); - } - }); + it.effect("fails on a malformed [storage] enabled string", () => { + return withConfig(["[storage]", 'enabled = "nope"', ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Invalid storage.enabled in supabase/config.toml", + ); + } + }), + ), + ); }); it.effect("fails with DbConfigLoadError when config.toml is present but unreadable", () => { @@ -1511,13 +1463,34 @@ describe("readDbToml", () => { expect(v.port).toBe(55555); expect(v.shadowPort).toBe(55556); expect(v.password).toBe("hunter2"); - expect(Option.getOrNull(v.projectId)).toBe("my-project"); + expect(v.projectId).toBe("my-project"); }), ), ); }); - it.effect("expands env(VAR) for password and port like Go's LoadEnvHook", () => { + it.effect("reads the db subtree and project_id from supabase/config.json", () => { + return withConfig(undefined).pipe( + Effect.tap((dir) => makeDir(dir, ["supabase"])), + Effect.tap((dir) => + writeFile( + dir, + ["supabase", "config.json"], + JSON.stringify({ project_id: "json-project", db: { port: 55777, shadow_port: 55778 } }), + ), + ), + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.port).toBe(55777); + expect(v.shadowPort).toBe(55778); + expect(v.projectId).toBe("json-project"); + }), + ), + ); + }); + + it.effect("expands env(VAR) for password and port", () => { return withConfig( ["[db]", 'port = "env(DB_PORT)"', 'password = "env(DB_PW)"', ""].join("\n"), ).pipe( @@ -1544,7 +1517,7 @@ describe("readDbToml", () => { ); }); - it.effect("honors SUPABASE_DB_SEED_ENABLED over the TOML value (Go AutomaticEnv)", () => { + it.effect("honors SUPABASE_DB_SEED_ENABLED over the TOML value", () => { return withConfig(["[db.seed]", "enabled = true", ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -1556,7 +1529,7 @@ describe("readDbToml", () => { ); }); - it.effect("expands an env() indirection in SUPABASE_DB_SEED_ENABLED (Go LoadEnvHook)", () => { + it.effect("expands an env() indirection in SUPABASE_DB_SEED_ENABLED", () => { return withConfig(["[db.seed]", "enabled = true", ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -1568,7 +1541,7 @@ describe("readDbToml", () => { ); }); - it.effect("honors SUPABASE_DB_MIGRATIONS_ENABLED over the default (Go AutomaticEnv)", () => { + it.effect("honors SUPABASE_DB_MIGRATIONS_ENABLED over the default", () => { return withConfig(undefined).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -1593,20 +1566,17 @@ describe("readDbToml", () => { ); }); - it.effect( - "expands env(VAR) for the top-level project_id (Go config.Load before Docker IDs)", - () => { - return withConfig(['project_id = "env(PROJECT_REF)"', ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("abcdefghijklmnopqrst"); - }), - ), - withEnv({ PROJECT_REF: "abcdefghijklmnopqrst" }), - ); - }, - ); + it.effect("expands env(VAR) for the top-level project_id", () => { + return withConfig(['project_id = "env(PROJECT_REF)"', ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectId).toBe("abcdefghijklmnopqrst"); + }), + ), + withEnv({ PROJECT_REF: "abcdefghijklmnopqrst" }), + ); + }); it.effect("does not merge a remote block whose project_id is a TOML env() literal", () => { // Remote matching happens on the raw `env(...)` literal, before expansion, so this block is @@ -1676,59 +1646,35 @@ describe("readDbToml", () => { ); }); - it.effect( - "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is absent", - () => { - return withConfig( - [ - "[db]", - "major_version = 17", - "[experimental]", - 'orioledb_version = "15.1.0.150"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); - }), - ), - ); - }, - ); - - it.effect( - "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is empty", - () => { - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = ""', - "[experimental]", - 'orioledb_version = "15.1.0.150"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); - }), - ), - ); - }, - ); + it.effect("rejects a non-empty experimental.orioledb_version, naming db.orioledb_version", () => { + return withConfig( + ["[db]", "major_version = 17", "[experimental]", 'orioledb_version = "15.1.0.150"', ""].join( + "\n", + ), + ).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "experimental.orioledb_version in supabase/config.toml is no longer supported. Use db.orioledb_version instead.", + ); + } + }), + ), + ); + }); - it.effect("prefers an explicit db.orioledb_version over a non-empty legacy value", () => { + it.effect("ignores an empty experimental.orioledb_version", () => { return withConfig( [ "[db]", "major_version = 17", 'orioledb_version = "17.0.0.1"', "[experimental]", - 'orioledb_version = "15.1.0.150"', + 'orioledb_version = ""', "", ].join("\n"), ).pipe( @@ -1741,62 +1687,52 @@ describe("readDbToml", () => { ); }); - it.effect( - "a matched remote's legacy experimental.orioledb_version overrides the base db.orioledb_version", - () => { - // Matches `@supabase/config`'s loader precedence: each `[remotes.*]` block's own legacy - // value is promoted before the remote merge, so it can override the base canonical value. - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = "A"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental]", - 'orioledb_version = "B"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); - }), - ), - ); - }, - ); + it.effect("a matched remote's db.orioledb_version overrides the base db.orioledb_version", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.db]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + }), + ), + ); + }); - it.effect( - "a matched remote's legacy experimental.orioledb_version still beats a conflicting SUPABASE_DB_ORIOLEDB_VERSION", - () => { - // Same precedence as any other `ENV_OVERRIDABLE_KEYS` field (e.g. db.major_version): - // an explicit remote value beats its matching `SUPABASE_*` env override. - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[db]", - "major_version = 17", - 'orioledb_version = "A"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental]", - 'orioledb_version = "B"', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); - }), - ), - withEnv({ SUPABASE_DB_ORIOLEDB_VERSION: "env-value" }), - ); - }, - ); + it.effect("SUPABASE_DB_ORIOLEDB_VERSION beats a matched remote's db.orioledb_version", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.db]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("env-value"); + }), + ), + withEnv({ SUPABASE_DB_ORIOLEDB_VERSION: "env-value" }), + ); + }); it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { const writes: Array = []; @@ -1856,7 +1792,7 @@ describe("readDbToml", () => { return yield* readDbToml(fs, path, dir, undefined, { warnOnUnresolvedEnv: false }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(servicesLive), Effect.tap((v) => Effect.sync(() => { // Config load still succeeds and still resolves the value; only the @@ -1895,8 +1831,8 @@ describe("readDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError"); - expect(Cause.pretty(exit.cause)).toContain("invalid db.port"); + expect(Cause.pretty(exit.cause)).toContain("CliConfigValueError"); + expect(Cause.pretty(exit.cause)).toContain("Invalid db.port"); } }), ), @@ -1913,14 +1849,14 @@ describe("readDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("invalid db.shadow_port"); + expect(Cause.pretty(exit.cause)).toContain("Invalid db.shadow_port"); } }), ), ); }); - it.effect("resolves env(VAR) from the project supabase/.env file (Go loadNestedEnv)", () => { + it.effect("resolves env(VAR) from the project supabase/.env file", () => { return withConfig( ["[db]", 'port = "env(DB_FILEVAR)"', 'password = "env(DB_FILEVAR)"', ""].join("\n"), ).pipe( @@ -1949,7 +1885,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets supabase/.env win over a repo-root .env (Go walks supabase/ first)", () => { + it.effect("lets supabase/.env win over a repo-root .env", () => { return withConfig(["[db]", 'password = "env(DB_FILEVAR)"', ""].join("\n")).pipe( Effect.tap((dir) => writeFile(dir, [".env"], "DB_FILEVAR=root\n")), Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], "DB_FILEVAR=supabase\n")), @@ -1996,7 +1932,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets SUPABASE_DB_* env vars override the [db] config (viper AutomaticEnv)", () => { + it.effect("lets SUPABASE_DB_* env vars override the [db] config", () => { return withConfig( ["[db]", "port = 55555", "shadow_port = 55556", 'password = "hunter2"', ""].join("\n"), ).pipe( @@ -2029,7 +1965,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + it.effect("rejects db.major_version = 0 with the missing-required message", () => { return withConfig(["[db]", "major_version = 0", ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -2046,7 +1982,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { + it.effect("rejects db.major_version = 12 with the 12.x message", () => { return withConfig(["[db]", "major_version = 12", ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -2098,7 +2034,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "Failed reading config: Invalid db.major_version: 17foo.", + 'Invalid db.major_version in supabase/config.toml: "17foo" is not a non-negative integer.', ); } }), @@ -2106,7 +2042,7 @@ describe("readDbToml", () => { ); }); - it.effect("expands env(VAR) for db.major_version like Go's LoadEnvHook", () => { + it.effect("expands env(VAR) for db.major_version", () => { return withConfig(["[db]", 'major_version = "env(PG_MAJOR)"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -2151,7 +2087,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "Failed reading config: Invalid edge_runtime.deno_version: 2foo.", + 'Invalid edge_runtime.deno_version in supabase/config.toml: "2foo" is not a non-negative integer.', ); } }), @@ -2159,7 +2095,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects a malformed [remotes.*] project_id on every load (Go Validate)", () => { + it.effect("rejects a malformed [remotes.*] project_id on every load", () => { return withConfig(["[remotes.staging]", 'project_id = "staging"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -2189,7 +2125,7 @@ describe("readDbToml", () => { ); }); - it.effect("ignores an empty SUPABASE_DB_PORT override (viper AllowEmptyEnv=false)", () => { + it.effect("ignores an empty SUPABASE_DB_PORT override", () => { return withConfig(["[db]", "port = 55555", ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => @@ -2201,25 +2137,28 @@ describe("readDbToml", () => { ); }); - it.effect("loadProjectEnv surfaces SUPABASE_DB_PASSWORD from .env (linked-path source)", () => { - // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value - // defined only in supabase/.env must be visible (Go's loadNestedEnv parity). - return withConfig(undefined).pipe( - Effect.tap((dir) => makeDir(dir, ["supabase"])), - Effect.tap((dir) => - writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PASSWORD=from-dotenv\n"), - ), - Effect.flatMap(loadEnv), - Effect.tap((env) => - Effect.sync(() => { - expect(env["SUPABASE_DB_PASSWORD"]).toBe("from-dotenv"); - }), - ), - withEnv({}), - ); - }); + it.effect( + "loadProjectEnvValues surfaces SUPABASE_DB_PASSWORD from .env (linked-path source)", + () => { + // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value + // defined only in supabase/.env must be visible. + return withConfig(undefined).pipe( + Effect.tap((dir) => makeDir(dir, ["supabase"])), + Effect.tap((dir) => + writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PASSWORD=from-dotenv\n"), + ), + Effect.flatMap(loadEnv), + Effect.tap((env) => + Effect.sync(() => { + expect(env["SUPABASE_DB_PASSWORD"]).toBe("from-dotenv"); + }), + ), + withEnv({}), + ); + }, + ); - it.effect("loadProjectEnv is pure: returns every key and never touches process.env", () => { + it.effect("loadProjectEnvValues is pure: returns every key and never touches process.env", () => { // A mere load for SUPABASE_YES has no global side effect. const processEnvValues = Effect.forEach( ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"], @@ -2268,12 +2207,12 @@ describe("readDbToml", () => { }); describe("readDbToml [experimental.pgdelta]", () => { - it.effect("defaults pg-delta to disabled with no config", () => { + it.effect("defaults pg-delta to enabled with no config", () => { return withConfig(undefined).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(false); + expect(v.pgDelta.enabled).toBe(true); expect(Option.isNone(v.pgDelta.declarativeSchemaPath)).toBe(true); expect(Option.isNone(v.pgDelta.formatOptions)).toBe(true); }), @@ -2285,7 +2224,7 @@ describe("readDbToml [experimental.pgdelta]", () => { return withConfig( [ "[experimental.pgdelta]", - "enabled = true", + "enabled = false", 'declarative_schema_path = "./db/decl"', 'format_options = "{\\"keywordCase\\":\\"upper\\",\\"indent\\":2}"', "", @@ -2295,7 +2234,7 @@ describe("readDbToml [experimental.pgdelta]", () => { Effect.tap((v) => Effect.gen(function* () { const path = yield* Path.Path; - expect(v.pgDelta.enabled).toBe(true); + expect(v.pgDelta.enabled).toBe(false); expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe( path.join("supabase", "db", "decl"), ); @@ -2308,18 +2247,22 @@ describe("readDbToml [experimental.pgdelta]", () => { ); }); - it.effect("keeps an absolute declarative_schema_path unchanged", () => { - return withConfig( - ["[experimental.pgdelta]", 'declarative_schema_path = "/abs/decl"', ""].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe("/abs/decl"); - }), - ), - ); - }); + it.effect( + "keeps an absolute declarative_schema_path and enables pg-delta when enabled is omitted", + () => { + return withConfig( + ["[experimental.pgdelta]", 'declarative_schema_path = "/abs/decl"', ""].join("\n"), + ).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.pgDelta.enabled).toBe(true); + expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe("/abs/decl"); + }), + ), + ); + }, + ); }); describe("resolveDeclarativeDir", () => { @@ -2350,7 +2293,7 @@ describe("resolveDeclarativeDir", () => { ); }); -describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => { +describe("readDbToml auth.Enabled validation", () => { // Fails the config load with `message` contained in the surfaced error. const failsWith = ( lines: ReadonlyArray, @@ -2375,7 +2318,7 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => it.effect("rejects an explicit empty auth.site_url", () => failsWith(["[auth]", 'site_url = ""'], "Missing required field in config: auth.site_url"), ); - it.effect("defaults an absent auth.site_url (Go template default) — no error", () => + it.effect("defaults an absent auth.site_url — no error", () => succeeds(["[auth]", "enabled = true"]), ); it.effect("skips all auth validation when auth.enabled = false", () => @@ -2489,6 +2432,12 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => "Invalid config for auth.email.template.invite.content: please use content_path instead", ), ); + it.effect("rejects an email template whose content comes from the shell environment", () => + failsWith( + ["[auth.email.template.invite]", 'subject = "Welcome"'], + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ).pipe(withEnv({ SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT: "

hi

" })), + ); it.effect("rejects an email template whose content_path file is missing", () => failsWith( ["[auth.email.template.invite]", 'content_path = "./missing.html"'], @@ -2509,11 +2458,31 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => "Missing required field in config: auth.external.github.client_id", ), ); + it.effect("rejects an enabled custom external provider without a client_id", () => + failsWith( + ["[auth.external.my_oidc]", "enabled = true"], + "Missing required field in config: auth.external.my_oidc.client_id", + ), + ); + it.effect("rejects an enabled custom external provider without a secret", () => + failsWith( + ["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"'], + "Missing required field in config: auth.external.my_oidc.secret", + ), + ); + it.effect("accepts a custom external provider whose secret comes from the shell", () => + succeeds(["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"']).pipe( + withEnv({ SUPABASE_AUTH_EXTERNAL_MY_OIDC_SECRET: "shh" }), + ), + ); it.effect("exempts apple/google from the external secret requirement", () => succeeds(["[auth.external.apple]", "enabled = true", 'client_id = "a"']), ); - it.effect("never validates the deprecated linkedin/slack providers", () => - succeeds(["[auth.external.linkedin]", "enabled = true"]), + it.effect("rejects the removed linkedin provider, naming linkedin_oidc", () => + failsWith( + ["[auth.external.linkedin]", "enabled = true"], + "[auth.external.linkedin] in supabase/config.toml is no longer supported. Use [auth.external.linkedin_oidc] instead.", + ), ); it.effect("rejects an enabled firebase third_party without project_id", () => @@ -2561,7 +2530,7 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ), ); - it.effect("defaults [auth.email.smtp] enabled=true when the table omits enabled (Go merge)", () => + it.effect("defaults [auth.email.smtp] enabled=true when the table omits enabled", () => failsWith( ["[auth.email.smtp]", 'user = "u"'], "Missing required field in config: auth.email.smtp.host", @@ -2584,19 +2553,19 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => it.effect("fails on a malformed auth boolean string instead of coercing to false", () => failsWith( ["[auth.passkey]", 'enabled = "maybe"'], - "failed to parse config: invalid auth.passkey.enabled.", + "Invalid auth.passkey.enabled in supabase/config.toml", ), ); - it.effect("rejects an unknown captcha provider (Go enum, regardless of enabled)", () => + it.effect("rejects an unknown captcha provider, regardless of enabled", () => failsWith( ["[auth.captcha]", "enabled = false", 'provider = "cloudflare"'], - "'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "Invalid auth.captcha.provider in supabase/config.toml", ), ); }); -describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parity)", () => { +describe("readDbToml encrypted secret decryption", () => { // An undecryptable `encrypted:` value anywhere in config.toml aborts the load with // `failed to parse config: `. const expectFails = (lines: ReadonlyArray, message: string) => @@ -2633,7 +2602,7 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit it.effect("accepts a plain (non-encrypted) secret value", () => expectLoads(["[db]", 'root_key = "plaintext-not-encrypted"']), ); - it.effect("treats an unset env() secret as a no-op (verbatim, like Go's hook)", () => + it.effect("treats an unset env() secret as a no-op (verbatim)", () => expectLoads(["[db]", 'root_key = "env(SOME_UNSET_ROOT_KEY)"']), ); it.effect("does NOT decrypt a non-secret string that starts with encrypted:", () => @@ -2678,7 +2647,7 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit ); }); -describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () => { +describe("readDbToml non-scalar config booleans", () => { // A present non-scalar boolean must fail the config load rather than falling through to the // schema default, which would let `db reset` prompt and drop schemas. const failsInvalid = (lines: ReadonlyArray, field: string) => @@ -2687,7 +2656,7 @@ describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () const exit = yield* read(dir).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain(`failed to parse config: invalid ${field}.`); + expect(Cause.pretty(exit.cause)).toContain(`Invalid ${field} in supabase/config.toml`); } }); it.effect("rejects an array value for [db.migrations] enabled", () => @@ -2698,7 +2667,61 @@ describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () ); }); -describe("readDbToml empty project_id (Go config.Validate parity)", () => { +describe("readDbToml project env scoping", () => { + const LINKED = "linkedlinkedlinkedlin"; + const OTHER = "otherotherotherother"; + + const withEnvFile = (config: string, dotEnv: string, linkedRef?: string) => + withConfig(config).pipe( + Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], dotEnv)), + Effect.tap((dir) => + linkedRef === undefined + ? Effect.void + : makeDir(dir, ["supabase", ".temp"]).pipe( + Effect.andThen(writeFile(dir, ["supabase", ".temp", "project-ref"], linkedRef)), + ), + ), + ); + + it.effect("omits a linked project's .env credential when targeting another project", () => { + return withEnvFile( + 'project_id = "demo"\n', + "SUPABASE_DB_PASSWORD=linked-secret\nUNRELATED=kept\n", + LINKED, + ).pipe( + Effect.flatMap((dir) => readRef(dir, OTHER)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectEnv).toEqual({ UNRELATED: "kept" }); + }), + ), + ); + }); + + it.effect("keeps the .env credential when targeting the linked project", () => { + return withEnvFile( + 'project_id = "demo"\n', + "SUPABASE_DB_PASSWORD=linked-secret\n", + LINKED, + ).pipe( + Effect.flatMap((dir) => readRef(dir, LINKED)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectEnv).toEqual({ SUPABASE_DB_PASSWORD: "linked-secret" }); + }), + ), + ); + }); + + it.effect("lets an empty shell value shadow the .env value an env() secret refers to", () => { + return withEnvFile( + '[db]\nroot_key = "env(SHADOWED_ROOT_KEY)"\n', + "SHADOWED_ROOT_KEY=encrypted:undecryptable\n", + ).pipe(Effect.flatMap(read), withEnv({ SHADOWED_ROOT_KEY: "" })); + }); +}); + +describe("readDbToml empty project_id", () => { it.effect("rejects a present-but-empty top-level project_id", () => { return withConfig('project_id = ""\n').pipe( Effect.flatMap(read), @@ -2728,7 +2751,7 @@ describe("readDbToml empty project_id (Go config.Validate parity)", () => { }); }); -describe("readDbToml [analytics] validation (Go config.Validate parity)", () => { +describe("readDbToml [analytics] validation", () => { const failsWith = (lines: ReadonlyArray, message: string) => Effect.gen(function* () { const dir = yield* withConfig(lines.join("\n")); @@ -2746,7 +2769,7 @@ describe("readDbToml [analytics] validation (Go config.Validate parity)", () => it.effect("rejects an unknown analytics.backend regardless of enabled", () => failsWith( ["[analytics]", "enabled = false", 'backend = "clickhouse"'], - "'analytics.backend' must be one of [postgres bigquery]", + "Invalid analytics.backend in supabase/config.toml", ), ); it.effect("rejects bigquery analytics missing gcp_project_id", () => @@ -2800,13 +2823,13 @@ describe("readDbToml [analytics] validation (Go config.Validate parity)", () => }); }); -describe("readDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () => { +describe("readDbToml SUPABASE_PROJECT_ID override", () => { it.effect("overrides the TOML project_id with SUPABASE_PROJECT_ID", () => { return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("env-project"); + expect(v.projectId).toBe("env-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "env-project" }), @@ -2818,147 +2841,54 @@ describe("readDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("env-project"); + expect(v.projectId).toBe("env-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "env-project" }), ); }); - it.effect("ignores an empty SUPABASE_PROJECT_ID (viper AllowEmptyEnv=false)", () => { + it.effect("ignores an empty SUPABASE_PROJECT_ID", () => { return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("toml-project"); + expect(v.projectId).toBe("toml-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "" }), ); }); - it.effect( - "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", - () => { - const ref = "abcdefghijklmnopqrst"; - return withConfig( - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("project_id")).toBe(true); - expect(Option.getOrNull(v.projectId)).toBe(ref); - }), - ), - withEnv({ SUPABASE_PROJECT_ID: "local" }), - ); - }, - ); - - it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + it.effect("SUPABASE_PROJECT_ID beats a matched [remotes.]'s project_id", () => { const ref = "abcdefghijklmnopqrst"; - return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBeUndefined(); - expect(Option.getOrNull(v.projectId)).toBe("env-project"); - }), - ), - withEnv({ SUPABASE_PROJECT_ID: "env-project" }), - ); - }); -}); - -describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.template/notification", () => { - const ref = "abcdefghijklmnopqrst"; - - it.effect("tracks auth.captcha.provider when a matched remote block supplies it", () => { - // `provider` is a plain string leaf, not part of a dynamically-keyed section, so it must be - // tracked via `ENV_OVERRIDABLE_KEYS` like any other fixed-name field. return withConfig( - [ - "[auth.captcha]", - 'provider = "hcaptcha"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.captcha]", - 'provider = "turnstile"', - "", - ].join("\n"), + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), ).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("auth.captcha.provider")).toBe(true); + expect(v.projectId).toBe("local"); }), ), + withEnv({ SUPABASE_PROJECT_ID: "local" }), ); }); - it.effect("tracks a matched remote block's auth.email.template. leaves dynamically", () => { - // `auth.email.template..*` is an arbitrarily-keyed map, same shape as - // `auth.external..*`, so it must be flattened dynamically instead of relying on a - // fixed `ENV_OVERRIDABLE_KEYS` entry. - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.email.template.invite]", - 'content_path = "remote-invite.html"', - "", - ].join("\n"), - ).pipe( - // Template `content_path` resolves relative to the project root (`workdir`, i.e. `dir`). - Effect.tap((dir) => writeFile(dir, ["remote-invite.html"], "")), + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("auth.email.template.invite.content_path")).toBe(true); - expect(v.remoteOverrideKeys.has("auth.email.template.invite.subject")).toBe(false); + expect(v.appliedRemote).toBeUndefined(); + expect(v.projectId).toBe("env-project"); }), ), + withEnv({ SUPABASE_PROJECT_ID: "env-project" }), ); }); - - it.effect( - "tracks a matched remote block's auth.email.notification. leaves dynamically", - () => { - // Sibling case to auth.email.template, including a boolean leaf (`enabled`). - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.email.notification.password_changed]", - "enabled = true", - 'content_path = "remote-pw-changed.html"', - "", - ].join("\n"), - ).pipe( - // Notification `content_path` resolves relative to the project root, like a template. - Effect.tap((dir) => writeFile(dir, ["remote-pw-changed.html"], "")), - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.enabled"), - ).toBe(true); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.content_path"), - ).toBe(true); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.subject"), - ).toBe(false); - }), - ), - ); - }, - ); }); describe("readDbToml OrioleDB telemetry", () => { @@ -2990,3 +2920,32 @@ describe("readDbToml OrioleDB telemetry", () => { ); }); }); + +describe("readDbToml Data API exposure", () => { + it.effect.each([ + { + name: "disabled in config", + config: '[api]\nenabled = false\nschemas = ["app"]', + env: { SUPABASE_API_ENABLED: "" }, + expected: [], + }, + { + name: "disabled by environment", + config: "[api]\nenabled = true", + env: { SUPABASE_API_ENABLED: "false" }, + expected: [], + }, + { + name: "trimmed and deduplicated", + config: '[api]\nschemas = ["app", " public", "app"]', + env: { SUPABASE_API_ENABLED: "" }, + expected: ["app", "public"], + }, + ])("resolves exposed schemas: $name", ({ config, env, expected }) => + withConfig(config).pipe( + Effect.flatMap(read), + Effect.tap((values) => Effect.sync(() => expect(values.exposedSchemas).toEqual(expected))), + withEnv(env), + ), + ); +}); diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 508507dbc8..4b48cd7364 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -1,59 +1,7 @@ import type { Option } from "effect"; -import type { ResolvedRemoteTarget } from "../config/remote-target.service.ts"; import type { PgConnInput } from "./db-connection.service.ts"; -import type { DbConnType } from "./db-target-flags.ts"; - -/** - * The connection-resolution flags shared by `db lint`, `db advisors`, and `test db`. - * - * `connType` records which selector flag the user explicitly set, derived from raw argv via - * `resolveDbTargetFlags`: - * - "db-url" → `--db-url` was set (read `dbUrl.value`) - * - "linked" → `--linked` was set (Management API path) - * - "local" → `--local` was set (explicit local path) - * - undefined → no selector was set; resolver defaults to local - * - * `--db-url` / `--linked` / `--local` are mutually exclusive. `dnsResolver` carries the global - * `--dns-resolver` value, used when the resolver opens its own remote connection (the linked - * pooler temp-role probe); the handler passes the same value to its primary `connect`. - */ -export interface DbConfigFlags { - readonly dbUrl: Option.Option; - readonly connType: DbConnType | undefined; - readonly dnsResolver: "native" | "https"; - /** - * Whether config resolution should decrypt and materialize `[db.vault]` values. - * Defaults to true; `db push --skip-vault` is the only caller that disables it. - */ - readonly resolveVaultSecrets?: boolean; - /** - * The `--password`/`-p` flag value. When `Some`, it takes precedence over the - * `SUPABASE_DB_PASSWORD` env var on the linked path. Commands without a `--password` flag - * (e.g. `test db`) omit it, and the resolver falls back to env only. - */ - readonly password?: Option.Option; - /** - * Optional explicit linked project ref override, letting e.g. `gen types --project-id ` - * reuse the linked resolver's temp-role and pooler fallback without the workdir being linked. - * Shares only `SUPABASE_PROJECT_ID`'s ref-resolution precedence (flag > env > - * the link) — it never drives the local container id or pg-delta project id the way - * that env var does. `None` preserves the normal `--linked` fallback. - */ - readonly linkedProjectRef?: Option.Option; - /** - * The target the caller already resolved through `RemoteTarget`. The `--linked` path uses it - * as is instead of resolving again, and takes the saved pooler URL from it. - */ - readonly target?: ResolvedRemoteTarget; - /** - * Marks `linkedProjectRef` as an ad-hoc remote target (e.g. `gen types --project-id `) - * rather than the current linked workdir, so the resolver must not reuse workdir-scoped - * credentials or cached state for it: it ignores the ambient `SUPABASE_DB_PASSWORD` and skips - * the saved pooler URL, fetching pooler config from the Management API instead. Absent - * for the normal `--linked` path, which may reuse both. - */ - readonly adHocProjectRef?: boolean; -} +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; /** * A resolved Postgres connection plus whether it points at the local stack. `isLocal` decides @@ -62,10 +10,29 @@ export interface DbConfigFlags { export interface ResolvedDbConfig { readonly conn: PgConnInput; readonly isLocal: boolean; + /** Schemas the local config exposes through the Data API; set on the `--local` and `--db-url` paths. */ + readonly exposedSchemas?: ReadonlyArray; /** - * The resolved linked project ref (`--linked` path only; `None` for `--local` / `--db-url`). + * The resolved project ref (hosted targets only; `None` for a local or database URL target). * Lets the caller re-read config with the ref applied so a matching `[remotes.]` block can * override fields like `db.major_version` for the container image. */ readonly ref?: Option.Option; } + +/** What {@link DbConfigResolver.resolveConnection} needs: the target and how to authenticate. */ +export interface DbConnectionInput { + readonly target: ResolvedTarget; + readonly dnsResolver: "native" | "https"; + /** Rejected unless the target is the linked project. */ + readonly password: Option.Option; + readonly resolveVaultSecrets?: boolean; + /** Set when the command binds `--password`, so the withheld-password notice can name it. */ + readonly hasPasswordFlag?: boolean; + /** A `--yes`-sourced `Some` lets the retry clear bans; else `LinkedDbNetworkBannedError`. */ + readonly networkBanRemoval: Option.Option; +} + +export interface ResolvedDbConnection extends ResolvedDbConfig { + readonly target: ResolvedTarget; +} diff --git a/apps/cli/src/command-internal/db-connection.service.ts b/apps/cli/src/command-internal/db-connection.service.ts index abba632ff3..e743401536 100644 --- a/apps/cli/src/command-internal/db-connection.service.ts +++ b/apps/cli/src/command-internal/db-connection.service.ts @@ -143,7 +143,7 @@ export interface QueryResult { readonly fields: ReadonlyArray; /** * Postgres type OID per column. Lets the table/CSV formatter render `float4`/`float8` columns - * with `%g`-style formatting while integer columns stay plain. Optional so other `queryRaw` + * in shortest round-trip form (exponent notation for very large or small magnitudes) while integer columns stay plain. Optional so other `queryRaw` * callers/mocks need not set it. */ readonly fieldTypeIds?: ReadonlyArray; diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts index d841c1a303..4f4d5264cf 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts @@ -468,35 +468,33 @@ describe("dbConnectionLayer connect failures", () => { }), ); - it.live( - "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", - () => - Effect.gen(function* () { - const server = yield* fakePostgresServer((socket) => { - socket.write( - errorResponse({ - S: "FATAL", - V: "FATAL", - C: "28P01", - M: 'password authentication failed for user "postgres"', - F: "auth.c", - L: "326", - R: "auth_failed", - }), - ); - socket.end(); - }); - const error = yield* connectFailure({ port: server.port }).pipe( - Effect.ensuring(Effect.sync(server.close)), - ); - expect(error.message).toBe( - "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + - 'server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))', + it.live("renders the server error for an auth failure and suggests SUPABASE_DB_PASSWORD", () => + Effect.gen(function* () { + const server = yield* fakePostgresServer((socket) => { + socket.write( + errorResponse({ + S: "FATAL", + V: "FATAL", + C: "28P01", + M: 'password authentication failed for user "postgres"', + F: "auth.c", + L: "326", + R: "auth_failed", + }), ); - expect(error.suggestion).toBe(SUGGEST_ENV_VAR); - expect(error.message).not.toContain(SENTINEL_PASSWORD); - expect(error.retryable).toBeUndefined(); - }), + socket.end(); + }); + const error = yield* connectFailure({ port: server.port }).pipe( + Effect.ensuring(Effect.sync(server.close)), + ); + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + + 'server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))', + ); + expect(error.suggestion).toBe(SUGGEST_ENV_VAR); + expect(error.message).not.toContain(SENTINEL_PASSWORD); + expect(error.retryable).toBeUndefined(); + }), ); it.live( @@ -521,7 +519,7 @@ describe("dbConnectionLayer connect failures", () => { describe("dbConnectionLayer exec failures", () => { it.live( - "maps a real wire ErrorResponse to pgconn's PgError rendering with detail and position", + "maps a real wire ErrorResponse to the server-error rendering with detail and position", () => // Tripwire for the server-error extraction: drives the real driver stack (node-postgres // wire parsing → `DatabaseError` → `@effect/sql-pg`'s `SqlError` cause chain → diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index fa99bc7109..7f761f07c5 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -8,12 +8,12 @@ import { ConnectionError, SqlError } from "effect/sql/SqlError"; import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; import { - SUGGEST_LOCAL_STACK, connectFailureMessage, connectSuggestion, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, + localStackSuggestion, } from "./connect-errors.ts"; import { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts"; import { makePoolSqlClient } from "./db-connection.pool-client.ts"; @@ -199,13 +199,14 @@ export function batchFailureError( error: Error, batch: { readonly completed: number; readonly outcome: BatchOutcome } | undefined, isLocal: boolean, + linkedCheckout?: boolean, ): DbExecError | DbConnectError { if (batch === undefined || batch.outcome === "unsent") { return new DbConnectError({ message: `${BATCH_CONNECTION_LOST}: ${error.message}`, // The checkout failure a tick earlier carries this same hint, so losing the // connection mid-batch must not silently drop it. - ...(isLocal ? { suggestion: SUGGEST_LOCAL_STACK } : {}), + ...(isLocal ? { suggestion: localStackSuggestion(linkedCheckout) } : {}), }); } const mapped = toExecError(error); @@ -963,7 +964,16 @@ const connect = ( resume(Effect.void); return; } - resume(Effect.fail(batchFailureError(error, batchQuery, options.isLocal))); + resume( + Effect.fail( + batchFailureError( + error, + batchQuery, + options.isLocal, + cfg.suggestionContext?.linkedCheckout, + ), + ), + ); }; batchQuery = new PgBatchQuery(statements, finish); try { @@ -1036,7 +1046,7 @@ const connect = ( return { fields: result.fields.map((field) => field.name), // Surface the column type OIDs so the table/CSV formatter can render float4/float8 - // with %g-style formatting while integer columns stay plain. + // in shortest round-trip form while integer columns stay plain. fieldTypeIds: result.fields.map((field) => field.dataTypeID), rows: result.rows, commandTag, diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index 0f8156f627..500139e65e 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -79,7 +79,7 @@ describe("buildConnectionUrl", () => { ); }); - it("forwards runtimeParams as -c flags in the options startup param (Go RuntimeParams)", () => { + it("forwards runtimeParams as -c flags in the options startup param", () => { const url = buildConnectionUrl( { user: "postgres", @@ -138,7 +138,7 @@ describe("sslOptionFor", () => { expect(sslOptionFor(undefined, false, undefined)).toEqual({ rejectUnauthorized: false }); }); - it("treats prefer/require as TLS without verification (their pgconn primary)", () => { + it("treats prefer/require as TLS without verification (their primary mode)", () => { expect(sslOptionFor("prefer", false, undefined)).toEqual({ rejectUnauthorized: false }); expect(sslOptionFor("require", false, undefined)).toEqual({ rejectUnauthorized: false }); }); @@ -154,7 +154,7 @@ describe("sslOptionFor", () => { }); }); - it("verifies the CA chain but skips hostname for verify-ca (pgconn parity)", () => { + it("verifies the CA chain but skips hostname for verify-ca", () => { const ssl = sslOptionFor("verify-ca", false, undefined); expect(ssl).toMatchObject({ rejectUnauthorized: true }); if (typeof ssl === "object" && ssl !== null) { @@ -163,7 +163,7 @@ describe("sslOptionFor", () => { } }); - it("attaches the client cert (cert/key/passphrase) to every TLS mode (pgconn parity)", () => { + it("attaches the client cert (cert/key/passphrase) to every TLS mode", () => { const clientCert = { cert: "CERT", key: "KEY", passphrase: "pw" }; expect(sslOptionFor("verify-full", false, undefined, undefined, clientCert)).toMatchObject({ cert: "CERT", @@ -184,7 +184,7 @@ describe("sslOptionFor", () => { }); }); - it("carries the servername for non-verifying TLS modes too (Go enables sslsni by default)", () => { + it("carries the servername for non-verifying TLS modes too", () => { expect(sslOptionFor("require", false, "db.example.com")).toEqual({ rejectUnauthorized: false, servername: "db.example.com", @@ -206,7 +206,7 @@ describe("sslOptionFor", () => { }); }); -describe("sslConfigsFor (pgconn fallback list)", () => { +describe("sslConfigsFor (fallback list)", () => { it("local connections try a single plaintext (no-TLS) config", () => { expect(sslConfigsFor(undefined, true, undefined)).toEqual([false]); }); @@ -215,7 +215,7 @@ describe("sslConfigsFor (pgconn fallback list)", () => { expect(sslConfigsFor("disable", false, undefined)).toEqual([false]); }); - it("allow is plaintext primary with a TLS fallback ({nil, tlsConfig})", () => { + it("allow is plaintext primary with a TLS fallback (plaintext first, then TLS)", () => { expect(sslConfigsFor("allow", false, undefined)).toEqual([ false, { rejectUnauthorized: false }, @@ -442,17 +442,15 @@ describe("poolStepDownVerify", () => { }), ); - it.effect( - "propagates a failing step-down to the pool callback so the checkout fails (Go AfterConnect parity)", - () => - Effect.gen(function* () { - const failure = new Error("permission denied to set role"); - const client = { query: () => Promise.reject(failure) }; - const done = yield* Effect.callback((resume) => { - poolStepDownVerify(client, (error) => resume(Effect.succeed(error))); - }); - expect(done).toBe(failure); - }), + it.effect("propagates a failing step-down to the pool callback so the checkout fails", () => + Effect.gen(function* () { + const failure = new Error("permission denied to set role"); + const client = { query: () => Promise.reject(failure) }; + const done = yield* Effect.callback((resume) => { + poolStepDownVerify(client, (error) => resume(Effect.succeed(error))); + }); + expect(done).toBe(failure); + }), ); it.effect("wraps a non-Error rejection into an Error for the pool callback", () => @@ -543,14 +541,14 @@ describe("isUnixSocketHost", () => { expect(isUnixSocketHost("::1")).toBe(false); }); - it("treats an uppercase Windows drive path as a socket, lowercase as TCP (pgconn parity)", () => { + it("treats an uppercase Windows drive path as a socket, lowercase as TCP", () => { expect(isUnixSocketHost("C:\\pgsql")).toBe(true); expect(isUnixSocketHost("c:\\pgsql")).toBe(false); expect(isUnixSocketHost("C:")).toBe(false); }); }); -describe("isTerminalConnectError (pgconn fallback termination)", () => { +describe("isTerminalConnectError (fallback termination)", () => { it("terminates on auth/catalog/privilege SQLSTATEs carried on the error cause", () => { // The pg driver attaches the SQLSTATE as `code`; @effect/sql wraps it in `cause`. expect(isTerminalConnectError({ cause: { code: "28P01" } }, false)).toBe(true); @@ -558,7 +556,7 @@ describe("isTerminalConnectError (pgconn fallback termination)", () => { expect(isTerminalConnectError({ code: "42501" }, false)).toBe(true); }); - it("gates 28000 on the attempt having used TLS (pgconn fc.TLSConfig != nil)", () => { + it("gates 28000 on the attempt having used TLS (a TLS attempt is required)", () => { expect(isTerminalConnectError({ code: "28000" }, true)).toBe(true); expect(isTerminalConnectError({ code: "28000" }, false)).toBe(false); }); @@ -591,7 +589,7 @@ describe("toExecError (pg server-error extraction)", () => { }), }); - it("renders pgconn's PgError message and carries code, detail, and position", () => { + it("renders the server error message and carries code, detail, and position", () => { const error = toExecError( sqlErrorChain({ message: 'type "ltree" does not exist', @@ -607,7 +605,7 @@ describe("toExecError (pg server-error extraction)", () => { expect(error.position).toBe(25); }); - it("omits detail and position when the server sent none (Go's non-empty gates)", () => { + it("omits detail and position when the server sent none", () => { const error = toExecError( sqlErrorChain({ message: 'syntax error at or near "BOOM"', @@ -793,6 +791,19 @@ describe("batchFailureError", () => { expect("suggestion" in remote).toBe(false); }); + it("adds the --remote pointer to the local-stack hint on a linked checkout", () => { + const error = batchFailureError( + new Error("socket died"), + { completed: 0, outcome: "unsent" }, + true, + true, + ); + + expect(error).toMatchObject({ + suggestion: `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --remote.`, + }); + }); + it("keeps a partially written batch on the statement path, blaming statement 0", () => { // A poisoned batch is corked, so `completed` stays 0 and statement 0 is blamed. const error = batchFailureError( diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 29ef955f5e..aa663df918 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -1,4 +1,4 @@ -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; +import { legacyProjectStatePaths } from "../shared/config/legacy-project-state.paths.ts"; import { usesStackState } from "./stack-backend.ts"; import { Effect, type FileSystem, type Path } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index 9ce7fc6fad..19eef68a28 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -71,7 +71,7 @@ describe("resolveDbImage", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.effect("rewrites to the OrioleDB image on a 15/17 project (Go config.Validate)", () => + it.effect("rewrites to the OrioleDB image on a 15/17 project", () => Effect.gen(function* () { const dir = yield* withTemp; // > 15.1.1.13 → `-orioledb` diff --git a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts new file mode 100644 index 0000000000..dc052493e1 --- /dev/null +++ b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts @@ -0,0 +1,175 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { rootCommandForFeatures } from "../cli/root.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { legacyProjectStateLayer } from "../shared/config/legacy-project-state.layer.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { walkCommandTree, type WalkedCommand } from "../../tests/helpers/command-tree.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, + processEnvLayer, +} from "../../tests/helpers/mocks.ts"; +import { choiceKeysOf } from "../docs/docs-introspection.ts"; +import { unwrapParam } from "./param-introspection.ts"; + +const root = rootCommandForFeatures(); + +const hasFlag = (entry: WalkedCommand, name: string) => + entry.flags.some((flag) => flag.name === name); + +const isTombstone = (entry: WalkedCommand) => + entry.command.description?.startsWith("Removed:") === true; + +const passwordCommands = walkCommandTree(root).filter( + (entry) => hasFlag(entry, "password") && !isTombstone(entry), +); + +type Selector = "db-url" | "remote-url" | "local" | "none"; + +const withSelector = (selector: Exclude) => + passwordCommands.filter((entry) => hasFlag(entry, selector === "local" ? "local" : "db-url")); + +const requiredFlagArgs = (entry: WalkedCommand): ReadonlyArray => + entry.flags.flatMap((flag) => { + const unwrapped = unwrapParam(flag.param); + const required = + unwrapped !== undefined && + !unwrapped.isOptional && + !(unwrapped.isVariadic && unwrapped.variadicMin === 0) && + unwrapped.single.primitiveType._tag !== "Boolean"; + if (!required) return []; + const [firstChoice] = choiceKeysOf(unwrapped.single.primitiveType) ?? []; + return [`--${flag.name}`, firstChoice ?? "value"]; + }); + +const DIRECT_URL = "postgres://user:secret@127.0.0.1:1/postgres"; + +const selectorArgs = (selector: Selector) => { + switch (selector) { + case "db-url": + return ["--db-url", DIRECT_URL]; + case "remote-url": + return [`--remote=${DIRECT_URL}`]; + case "local": + return ["--local"]; + case "none": + return []; + } +}; + +const runCommand = ( + entry: WalkedCommand, + selector: Selector, + options: { readonly linked?: boolean } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-password-rejection-" }); + if (options.linked === true) { + yield* fs.makeDirectory(path.join(home, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + } + const args = [ + ...entry.path, + ...requiredFlagArgs(entry), + "--password", + "from-flag", + ...selectorArgs(selector), + "--workdir", + home, + // `db pull` rejects `--experimental` without `--declarative` before it validates the password. + ...(entry.path.join(" ") === "db pull" ? [] : ["--experimental"]), + ]; + const exit = yield* Command.runWith(root, { version: "0.0.0-test" })(args).pipe( + Effect.provide( + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + mockOutput({ format: "text" }).layer, + BunServices.layer, + legacyProjectStateLayer.pipe(Layer.provide(BunServices.layer)), + mockRuntimeInfo(), + mockAnalytics().layer, + mockTelemetryRuntime(), + mockTty(), + mockProcessControl().layer, + processEnvLayer({ SUPABASE_HOME: home }), + ), + ), + Effect.exit, + ); + return Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +describe("--password with a direct database target", () => { + it("is discovered on commands that accept a direct target", () => { + expect(passwordCommands.length).toBeGreaterThan(5); + expect(withSelector("db-url").length).toBeGreaterThan(5); + expect(withSelector("remote-url").length).toBeGreaterThan(5); + expect(withSelector("local").length).toBeGreaterThan(5); + }); + + for (const selector of ["db-url", "remote-url", "local"] as const) { + for (const entry of withSelector(selector)) { + it.effect( + `rejects ${entry.path.join(" ")} --password with ${selector === "remote-url" ? "--remote=" : `--${selector}`}`, + () => + Effect.gen(function* () { + const failure = yield* runCommand(entry, selector); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", + message: + selector === "local" + ? "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml." + : "--password can't be used with --remote=. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres", + }); + }), + ); + } + } + + it.effect("rejects migration squash --password when no target is selected", () => + Effect.gen(function* () { + const entry = passwordCommands.find( + (candidate) => candidate.path.join(" ") === "migration squash", + ); + expect(entry).toBeDefined(); + const failure = yield* runCommand(entry!, "none"); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", + message: + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", + }); + }), + ); + + it.effect("points a local --password rejection at --remote when the checkout is linked", () => + Effect.gen(function* () { + const entry = passwordCommands.find( + (candidate) => candidate.path.join(" ") === "migration list", + ); + expect(entry).toBeDefined(); + const failure = yield* runCommand(entry!, "local", { linked: true }); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", + message: + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml. To use the linked project instead, pass --remote.", + }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/db-pull-run.errors.ts b/apps/cli/src/command-internal/db-pull-run.errors.ts index 6717bf6520..0a61e61212 100644 --- a/apps/cli/src/command-internal/db-pull-run.errors.ts +++ b/apps/cli/src/command-internal/db-pull-run.errors.ts @@ -30,8 +30,7 @@ export class DbPullInSyncError extends Data.TaggedError("DbPullInSyncError")<{ /** * Explains the non-zero exit instead of letting `Output.fail` append the generic "Try * rerunning the command with --debug" footer: an in-sync database is a finding, not a failure - * to troubleshoot. See `docs/go-cli-divergences.md` for the established message/exit-code - * contract this preserves. + * to troubleshoot. */ readonly suggestion: string; }> { diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 1fc8f4cbfe..09ed990e67 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -1,37 +1,28 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; -import { - DebugFlag, - DnsResolverFlag, - NetworkIdFlag, - resolveExperimentalWithProjectEnv, - resolveYesWithProjectEnv, -} from "./global-flags.ts"; -import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { RemoteTarget, type ResolvedRemoteTarget } from "../config/remote-target.service.ts"; +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { bold } from "./colors.ts"; -import { promptYesNo } from "./prompt-yes-no.ts"; import { ipv6Suggestion, isIPv6ConnectivityError } from "./connect-errors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; +import { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { flagsForTarget } from "./target/target-flags.ts"; import { resolveDbImage } from "./db-image.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; -import { - loadProjectEnv, - readDbToml, - resolveDeclarativeDir, - warnDeprecatedOrioleDBVersionEnv, -} from "./db-config.toml-read.ts"; -import type { DbConnType } from "./db-target-flags.ts"; +import { loadProjectEnvValues, readDbToml, resolveDeclarativeDir } from "./db-config.toml-read.ts"; import { makeDir } from "./make-dir.ts"; import { toPostgresURL } from "./postgres-url.ts"; -import { - buildLocalDbContainerInputs, - type LocalDbContainerInputs, -} from "./db-bootstrap/local-container-inputs.ts"; +import { buildLocalDbContainerInputs } from "./db-bootstrap/local-container-inputs.ts"; import { withShadowDatabase } from "./db-bootstrap/shadow-cache.ts"; import { shadowRunInputFromLocalContainerInputs } from "./db-bootstrap/shadow-database.ts"; import { LinkedProjectCache } from "../telemetry/linked-project-cache.service.ts"; @@ -41,13 +32,7 @@ import { warnPreservedUnmanagedDeclarativeFiles, writeDeclarativeSchemas, } from "../commands/db/shared/pgdelta.write.ts"; -import { - parseBoolEnv, - resolveDeclarativeFromArgs, - resolvePullDiffEngine, - schemaPathsTransitionWarning, - shouldUsePgDelta, -} from "./diff-engine.ts"; +import { resolvePullDiffEngine, schemaPathsTransitionWarning } from "./diff-engine.ts"; import { diffMigra } from "../commands/db/shared/migra.ts"; import { writePgDeltaMigrations } from "../commands/db/shared/pgdelta-migrations.write.ts"; import { type DumpOptions, buildSchemaDumpEnv } from "./pg-dump.env.ts"; @@ -59,6 +44,7 @@ import { import { emitPoolerFallbackWarning, isDirectLinkedHost, + orNoPoolerFallback, runWithPoolerFallback, } from "../commands/db/shared/pooler-fallback.ts"; import { dumpSchemaScript } from "./pg-dump.scripts.ts"; @@ -68,19 +54,14 @@ import { PgDeltaEngine, type PgDeltaDatabaseEndpoint, } from "../commands/db/shared/pgdelta-engine.service.ts"; -import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } from "./pgdelta.ts"; +import { type PgDeltaContext, isPgDeltaDebugEnabled } from "./pgdelta.ts"; import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; import { currentStackBackend, usesStackState } from "./stack-backend.ts"; import { selectionForRequest } from "./stack-project-selection.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase } from "./stack-shadow.ts"; -import type { DbPullFlags } from "../commands/db/pull/pull.command.ts"; -import { - DbPullDumpError, - DbPullEngineConflictError, - DbPullTargetFlagsError, - DbPullWriteError, -} from "../commands/db/pull/pull.errors.ts"; +import type { DbPullFlags as DbPullCommandFlags } from "../commands/db/pull/pull.command.ts"; +import { DbPullDumpError, DbPullWriteError } from "../commands/db/pull/pull.errors.ts"; import { DbPullInSyncError, DbPullMigrationConflictError } from "./db-pull-run.errors.ts"; import { listRemoteMigrations, @@ -89,19 +70,19 @@ import { } from "./migration-history.ts"; import { updateMigrationHistory } from "../commands/db/pull/pull.sync.ts"; -// `DbPullFlags` is `runDbPull`'s own parameter type, defined alongside the standalone -// `db pull` command that also constructs it — re-exported here so an in-process caller -// (`pull`) can build one without reaching into `commands/db/**` directly. -export type { DbPullFlags }; - -// Established output contract; ends with a `.`. -const DEPRECATION_LINE = - "Flag --use-pg-delta is deprecated and will be removed in v3, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; +/** + * `runDbPull`'s parameter type: the `db pull` command flags without the tombstoned + * `--use-pg-delta` and the target selectors, so an in-process caller (`pull`) can build one + * without reaching into `commands/db/**` directly. + */ +export type DbPullRunFlags = Omit< + DbPullCommandFlags, + "usePgDelta" | "remote" | "dbUrl" | "linked" | "local" | "projectRef" +>; /** * Explains the in-sync non-zero exit instead of the generic "Try rerunning the command with - * --debug…" footer, which would read like a crash for what is really a finding. See - * `docs/go-cli-divergences.md` for the established message/exit-code contract. + * --debug…" footer, which would read like a crash for what is really a finding. */ const IN_SYNC_SUGGESTION = "The remote database is already in sync with your local migrations — nothing to pull."; @@ -109,30 +90,14 @@ const IN_SYNC_SUGGESTION = /** Migration-file mode for the initial pg_dump seed. */ const MIGRATION_FILE_MODE = 0o644; -// `--experimental` without `--declarative` is a deprecated path: the in-process declarative -// export covers the same per-object-files outcome. Printed only when the experimental gate -// selected this branch, not when `--declarative` already did. -const EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE = - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3. Use --declarative instead to pull the remote schema as per-object files."; - export type DbPullInvoke = { - /** Skip printing `Finished supabase db pull.` for callers that must not emit it. */ - readonly skipFinishedLine?: boolean; /** - * Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for an - * in-process caller that already ran its own aggregated confirmation. - * `undefined` keeps the existing resolution. + * The consent an in-process caller already collected for the remote history update; `Some` + * skips this run's own question. */ - readonly assumeYes?: boolean; - /** - * Forces migration mode regardless of the ambient `--experimental`/`SUPABASE_EXPERIMENTAL` - * gate, for an in-process caller (`pull`) whose dirty-guard, confirmation message, and - * SIDE_EFFECTS.md all assume this step only ever writes into `supabase/migrations`. Without - * this override, an ambient experimental flag would silently switch to the declarative export - * path, writing `supabase/schemas/**` and potentially `config.toml`'s `schema_paths` - * unguarded. `undefined` keeps the existing resolution. - */ - readonly forceMigrationMode?: boolean; + readonly preapproved?: Option.Option; + /** Project `.env*` values the caller already loaded; `undefined` loads them here. */ + readonly projectEnv?: Record; }; export type DbPullOutcome = @@ -149,13 +114,14 @@ export type DbPullOutcome = }; /** - * Runs `db pull`'s target resolution and pull (declarative or migration mode) - * without emitting the final `output.success`/"Finished" line — the caller - * (`dbPull` for the standalone command, or an in-process orchestrator) - * owns that emission based on the returned {@link DbPullOutcome}. + * Runs `db pull` against an already-resolved target (declarative or migration mode) without + * emitting the final `output.success`/"Finished" line — the caller (`dbPull` for the + * standalone command, or an in-process orchestrator) owns that emission based on the returned + * {@link DbPullOutcome}. */ export const runDbPull = Effect.fn("db.pull.run")(function* ( - flags: DbPullFlags, + flags: DbPullRunFlags, + target: ResolvedTarget, invoke?: DbPullInvoke, ) { const output = yield* Output; @@ -169,152 +135,72 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; const debug = yield* DebugFlag; - const cliArgs = yield* CliArgs; + const configValues = yield* CliConfigValues; - // `--yes` or `SUPABASE_YES`. The project `.env` is loaded before the migration - // history prompt, so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms - // the native initial-migra history repair too. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); - // `EXPERIMENTAL` resolves from either the global `--experimental` flag or - // `SUPABASE_EXPERIMENTAL`, reusing `resolveExperimentalWithProjectEnv`'s flag-over-env - // precedence instead of re-deriving it. Resolved once, same as `yes` above; declarative mode - // ignores it. `invoke?.forceMigrationMode` overrides this for an in-process caller that must - // never take the declarative export path — see {@link DbPullInvoke.forceMigrationMode}. - const experimental = invoke?.forceMigrationMode - ? false - : yield* resolveExperimentalWithProjectEnv(projectEnv); + const projectEnv = + invoke?.projectEnv ?? (yield* loadProjectEnvValues(fs, path, cliSettings.workdir)); + const preapproved = invoke?.preapproved ?? Option.none(); - let linkedRefForCache: string | undefined; + const connType = target.kind; + // `--local`/`--db-url` never merge a `[remotes.*]` block, so only a linked target has a ref. + const linkedRef = target.kind === "linked" ? target.ref : undefined; return yield* Effect.gen(function* () { const name = Option.getOrElse(flags.name, () => "remote_schema"); - // `--declarative` and the deprecated `--use-pg-delta` both bind to the same - // `useDeclarative` outcome. When both are passed, the last occurrence in argv wins - // (e.g. `--declarative --use-pg-delta=false` => migration mode); since parsed Options don't - // carry order, the both-present case replays that rule off the raw argv instead of ORing the - // two, which would diverge on conflicting values. - const useDeclarative = - Option.isSome(flags.declarative) && Option.isSome(flags.usePgDelta) - ? (resolveDeclarativeFromArgs(cliArgs.args) ?? false) - : Option.getOrElse(flags.declarative, () => false) || - Option.getOrElse(flags.usePgDelta, () => false); - if (Option.isSome(flags.usePgDelta)) { - yield* output.raw(`${DEPRECATION_LINE}\n`, "stderr"); - } - // Deprecated `--experimental` dump: same in-process export as `--declarative`. - const useExperimentalExport = experimental && !useDeclarative; - if (useExperimentalExport) { - yield* output.raw(`${EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE}\n`, "stderr"); - } - const useDeclarativeExport = useDeclarative || useExperimentalExport; - - // Mutually exclusive flag groups: `[db-url linked local]`, `[declarative - // diff-engine]`, `[use-pg-delta diff-engine]`. "set" means the flag was - // explicitly passed. - const targetSet: Array = []; - if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); - if (Option.isSome(flags.linked)) targetSet.push("linked"); - if (Option.isSome(flags.local)) targetSet.push("local"); - if (targetSet.length > 1) { - return yield* new DbPullTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }); - } - for (const [flagName, present] of [ - ["declarative", Option.isSome(flags.declarative)], - ["use-pg-delta", Option.isSome(flags.usePgDelta)], - ] as const) { - if (present && Option.isSome(flags.diffEngine)) { - return yield* new DbPullEngineConflictError({ - message: `if any flags in the group [${flagName} diff-engine] are set none of the others can be; [${[flagName, "diff-engine"].sort().join(" ")}] were all set`, - }); - } - } + const useDeclarative = Option.getOrElse(flags.declarative, () => false); - const connType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : Option.isSome(flags.local) - ? "local" - : "linked"; yield* Effect.annotateCurrentSpan({ "db.conn_type": connType, - "db.pull.mode": useDeclarativeExport ? "declarative" : "migration", + "db.pull.mode": useDeclarative ? "declarative" : "migration", }); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbPullTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Pre-load the ref and re-read config here, before `resolver.resolve()` below, so the - // override print and merged-config validation happen before the connection attempt: a - // `resolve()` failure (bad password, unreachable host, network-ban lookup, …) must still - // tell the user which `[remotes.*]` block matched. `--local`/`--db-url` never merge a - // remote block, so only the linked path pre-resolves a ref. - let linkedRef: string | undefined; - let linkedTarget: ResolvedRemoteTarget | undefined; - if (connType === "linked") { - const remoteTarget = yield* RemoteTarget; - linkedTarget = yield* remoteTarget.load(flags.projectRef); - linkedRef = linkedTarget.ref; - // Cache the ref the moment it's known, not after `toml`/`localInputs` below resolve - // (both fallible): the project cache should still be written even when a later step - // (config validation, connection, the pull itself) fails. - linkedRefForCache = linkedRef; - } + // Read the config for the matching `[remotes.]` block before connecting, so a + // connection failure still reports which override matched. const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } - // Without the deprecated export, the shadow inputs' project-context load below prints it. - if (useExperimentalExport) yield* warnDeprecatedOrioleDBVersionEnv(toml.envLookup); + const resolvedConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(linkedRef), + }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; // Validate native shadow inputs before target resolution performs remote side effects. - const localInputs: Option.Option = useExperimentalExport - ? Option.none() - : Option.some( - yield* buildLocalDbContainerInputs( - spawner, - cliSettings.workdir, - networkIdFlag, - runtimeInfo.platform, - debug, - // So the shadow's own container spec reflects the matching `[remotes.]` - // override, same as `toml` above. - connType === "linked" ? linkedRef : undefined, - // `toml`'s remote-override-key tracking (same matched block), so a remote-set - // bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` env var here. - toml.remoteOverrideKeys, - ), - ); + const localInputs = yield* buildLocalDbContainerInputs( + spawner, + cliSettings.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + // So the shadow's own container spec reflects the matching `[remotes.]` + // override, same as `toml` above. + linkedRef, + ); - const resolved = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const connectionInput = { + target, dnsResolver, password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - target: linkedTarget, - }); - if (linkedRef === undefined) { - linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); - } - if (linkedRef !== undefined) linkedRefForCache = linkedRef; + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, + }; + const resolved = yield* resolver.resolveConnection(connectionInput); + const requireHistoryConsent = (migrations: number) => + Option.isSome(preapproved) + ? Effect.void + : requireTargetMutationConsent(resolved, { + action: "Update the remote migration history table", + details: [ + `Write ${migrations} migration file${migrations === 1 ? "" : "s"} to supabase/migrations.`, + "Record them as applied in the remote migration history.", + ], + }).pipe(Effect.asVoid); const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { - // Precedence: `SUPABASE_PROJECT_ID` env override, then config.toml's `project_id`, then - // the workdir basename fallback — with the matched `[remotes.]` block's own - // `project_id` suppressing the raw env argument on the linked path. - projectId: resolvePgDeltaProjectId(path, cliSettings.projectId, toml, cliSettings.workdir), + projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, cwd: cliSettings.workdir, denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, @@ -344,18 +230,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }) && isIPv6ConnectivityError(error.message) ) { - // A pooler resolution failure is treated as "no fallback" (re-fail the - // original diff error), not surfaced as its own error. - const pooler = yield* resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - target: linkedTarget, - }) - .pipe(Effect.orElseSucceed(() => Option.none())); + const pooler = yield* orNoPoolerFallback( + resolver.resolvePoolerFallback(connectionInput), + ); if (Option.isSome(pooler)) { yield* emitPoolerFallbackWarning(resolved.conn.host); return yield* attempt({ @@ -376,44 +253,32 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( engine: Option.getOrElse(flags.diffEngine, () => "migra"), pgDeltaDefault: (yield* currentStackBackend).kind === "stack" || - shouldUsePgDelta({ - configEnabled: toml.pgDelta.enabled, - usePgDeltaFlag: false, - envEnabled: parseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), - }), + (yield* resolvedConfig.get(CliConfigKeys.experimental.pgdelta.enabled)).value, }); if (Option.getOrElse(flags.diffEngine, () => "pg-delta") === "migra") { yield* stackRejectNativeDockerDiffEngine("--diff-engine migra"); } const diffEngine = usePgDeltaDiff ? "pg-delta" : "migra"; + const pgDeltaDebug = yield* isPgDeltaDebugEnabled; - // Connectivity check, run before dialing. return yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(resolved.conn, { isLocal: resolved.isLocal, dnsResolver, }); - // Declarative export path (`--declarative` or deprecated `--experimental`). - if (useDeclarativeExport) { + if (useDeclarative) { yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = resolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliSettings.workdir, declarativeDirRel); - const pgDeltaDebug = yield* isPgDeltaDebugEnabled; const exportSchema = (target: PgDeltaDatabaseEndpoint) => pgDeltaEngine.exportDeclarativeSchema({ context: ctx, target, schema: flags.schema, formatOptions, - ...(connType === "linked" && linkedRef !== undefined - ? { projectRef: linkedRef } - : {}), + ...(linkedRef !== undefined ? { projectRef: linkedRef } : {}), debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); @@ -463,7 +328,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( path, path.join(cliSettings.workdir, "supabase", "migrations"), ); - const sync = reconcileMigrations(remote, local, connType === "local"); + const sync = reconcileMigrations(remote, local, flagsForTarget(target)); if (sync.kind === "conflict") { return yield* new DbPullMigrationConflictError({ message: @@ -481,11 +346,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // is "in sync", a non-empty dump is a valid initial migration on its own. let seedWroteBytes = false; - // `Option.getOrThrow` is safe here: this point is only reached after the - // declarative-export branch already returned, so `localInputs` was always built. - const pullLocalInputs = Option.getOrThrow(localInputs); - if (seededFromDump) { + yield* requireHistoryConsent(1); yield* makeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new DbPullWriteError({ message: cause.message })), ); @@ -494,7 +356,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( projectRoot: cliSettings.workdir, majorVersion: toml.majorVersion, orioledbVersion: Option.getOrUndefined(toml.orioledbVersion), - services: pullLocalInputs.context.config.local?.services, + services: localInputs.context.config.local?.services, appliedRemote: toml.appliedRemote, })).database.image : (yield* resolveDbImage( @@ -591,16 +453,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( isLocal: resolved.isLocal, projectHost: cliSettings.projectHost, resolvePooler: () => - resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - target: linkedTarget, - }) - .pipe(Effect.orElseSucceed(() => Option.none())), + orNoPoolerFallback(resolver.resolvePoolerFallback(connectionInput)), runWithConn: runSchemaDump, reprintOnRetry: Effect.void, }); @@ -624,13 +477,13 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const stackBackend = (yield* currentStackBackend).kind === "stack"; const resolvedPullShadowImage = stackBackend ? "stack-ephemeral" - : yield* pullLocalInputs.resolvePostgresImage; + : yield* localInputs.resolvePostgresImage; const migrationMode: "legacy" | "pgdelta-next" = usePgDeltaDiff ? "pgdelta-next" : "legacy"; const shadowInput = { ...shadowRunInputFromLocalContainerInputs( - pullLocalInputs, + localInputs, resolvedPullShadowImage, toml, fs, @@ -639,7 +492,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( targetLocal: resolved.isLocal, migrationMode, // `toml.schemaPathPatterns` is used here, not the raw `@supabase/config` field on - // `pullLocalInputs`, since only `toml` (via `readDbToml`) resolves the + // `localInputs`, since only `toml` (via `readDbToml`) resolves the // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` env override. schemaPaths: toml.schemaPathPatterns, pgDelta: toml.pgDelta, @@ -680,7 +533,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }, schema: diffSchema, formatOptions, - debug: yield* isPgDeltaDebugEnabled, + debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); } @@ -738,6 +591,11 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }); } + // The initial migra pull asked before its dump wrote the file. + if (!seededFromDump) { + yield* requireHistoryConsent(usePgDeltaDiff ? (diffOutcome.files?.length ?? 0) : 1); + } + // Build the list of migration files to record in the remote history. The // migra engine writes exactly one file (the dump-seeded or freshly written // migrationPath); the pg-delta engine writes one ordered file per @@ -828,34 +686,18 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ); } - // Prompt to update the remote migration history table. Returns the default - // (`true`) on `--yes`, on an empty non-interactive stdin, or on any prompt error — - // it never fails the command. - let remoteHistoryUpdated = false; - const updateHistoryTitle = "Update remote migration history table?"; - // `invoke?.assumeYes` overrides this resolution entirely for an in-process - // caller that already ran its own aggregated confirmation. Otherwise, honors - // `--yes`, scans piped stdin on a non-TTY before falling back to the default, - // and otherwise prompts on a real TTY. - const shouldUpdate = - invoke?.assumeYes !== undefined - ? invoke.assumeYes - : yield* promptYesNo(output, yes, updateHistoryTitle, true); - if (shouldUpdate) { - // The migration file(s) in `writtenMigrations` are already on disk at this point, so - // a failure here must still report them as written: re-raise the same - // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. - yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( - Effect.mapError( - (cause) => - new DbPullWriteError({ - message: cause.message, - writtenSoFar: writtenMigrations.map((written) => written.path), - }), - ), - ); - remoteHistoryUpdated = true; - } + // The migration file(s) in `writtenMigrations` are already on disk at this point, so + // a failure here must still report them as written: re-raise the same + // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. + yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( + Effect.mapError( + (cause) => + new DbPullWriteError({ + message: cause.message, + writtenSoFar: writtenMigrations.map((written) => written.path), + }), + ), + ); return { kind: "migration", @@ -863,17 +705,13 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // writes one file per unit); `dbPull`'s emission derives the released `schemaWritten` // string field from its first entry. schemaFiles: writtenMigrations.map((written) => written.path), - remoteHistoryUpdated, + remoteHistoryUpdated: true, engine: diffEngine, } as const; }), ); }).pipe( - Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, - ), - ), + Effect.ensuring(linkedRef !== undefined ? linkedProjectCache.cache(linkedRef) : Effect.void), Effect.ensuring(telemetryState.flush), ); }); diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index d80305ffb5..b42c8104fe 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -1,7 +1,6 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; -import { promptYesNo } from "./prompt-yes-no.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; +import type { DbSeedPlan } from "./seed-remote-consent.ts"; import { Output } from "../shared/output/output.service.ts"; import { listLocalMigrations } from "./migration-list.ts"; import { @@ -14,7 +13,6 @@ import { import { type SeedFile, getPendingSeeds, seedData } from "./seed-ops.ts"; import { DbPushApplyError, - DbPushCancelledError, DbPushMissingLocalError, DbPushMissingRemoteError, DbPushRolesError, @@ -24,6 +22,8 @@ import type { DbTomlValues } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { applyMigrations, seedGlobals } from "./migration-apply.ts"; import { listRemoteMigrations, suggestRevertHistory } from "./migration-history.ts"; +import { requireTargetMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { upsertVaultSecrets } from "./vault.ts"; const CUSTOM_ROLES_PATH = "supabase/roles.sql"; @@ -38,12 +38,19 @@ const confirmSeedAll = (seeds: ReadonlyArray): string => .map((seed) => ` • ${bold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) .join(""); +const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? "" : "s"}`; + +const joinParts = (parts: ReadonlyArray): string => + parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; + +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + const applyError = (message: string) => new DbPushApplyError({ message }); /** * Everything `db push` does once its target connection and config are already resolved. Callers * (`db push`, `bootstrap`) resolve the project ref, connection, and `config.toml` themselves and - * pass the results in; this core never touches `RemoteTarget`/`DbConfigResolver` itself. + * pass the results in; this core never touches `ProjectRefResolver`/`DbConfigResolver` itself. * * The "DRY RUN: …" heads-up line prints first here, after the caller's own connection-resolution * output (e.g. "Initialising login role..."), so it lives here rather than at either call site. @@ -51,18 +58,12 @@ const applyError = (message: string) => new DbPushApplyError({ message }); export interface DbPushCoreInput { /** Absolute project directory (never read from `CommandSettings.workdir`). */ readonly workdir: string; - /** Resolved project ref, or `""` for `--local` / `--db-url`. */ + /** Resolved project ref, or `""` for a local or database URL target. */ readonly projectRef: string; readonly conn: PgConnInput; readonly isLocal: boolean; - /** - * Whether `--local` (not `--db-url`/`--linked`) was the explicit target selector, distinct - * from `isLocal` (whether the resolved connection happens to point at a local address, e.g. a - * `--db-url` pointing at `127.0.0.1`). Only feeds the "missing local migrations" repair - * suggestion's `--local` flag; `bootstrap` never selects `--local`, so it is always `false` - * there. - */ - readonly repairSuggestsLocalFlag: boolean; + /** Flags that point the "missing local migrations" repair suggestion at the pushed target. */ + readonly repairTargetFlags: string; /** * Gates the "DRY RUN: …" heads-up line, the "Would push/seed/create …" plan, and the JSON * `dryRun` field. @@ -71,12 +72,13 @@ export interface DbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; + readonly seed: DbSeedPlan; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ readonly toml: DbTomlValues; - /** Already resolved confirm-prompt default, e.g. via `resolveYesWithProjectEnv`. */ - readonly yes: boolean; + /** Consent the caller already obtained; `None` asks once after the plan (never for local). */ + readonly preapproved: Option.Option; /** * Standalone `db push` emits a `--output-format` json/stream-json success result for its own * invocation; `bootstrap` suppresses it since it emits its own top-level result instead. @@ -95,7 +97,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn projectRef, conn, isLocal, - repairSuggestsLocalFlag, + repairTargetFlags, dryRun, includeAll, includeRoles, @@ -103,8 +105,9 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn includeVault, dnsResolver, toml, - yes, + preapproved, emitStructuredResult, + seed, } = input; const vaultSecrets = toml.vault; @@ -115,12 +118,10 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); } - const databaseName = isLocal ? "local database" : "remote database"; const statusTarget = isLocal ? "Local database" : "Remote database"; yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); let pending: ReadonlyArray = []; @@ -137,7 +138,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn if (result.kind === "missing-local") { return yield* new DbPushMissingLocalError({ message: ERR_MISSING_LOCAL, - suggestion: suggestRevertHistory(result.versions, repairSuggestsLocalFlag), + suggestion: suggestRevertHistory(result.versions, repairTargetFlags), }); } if (result.kind === "missing-remote") { @@ -157,13 +158,13 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn let seeds: ReadonlyArray = []; if (includeSeed) { - if (!toml.seed.enabled) { + if (!seed.input.enabled) { yield* output.raw( `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, "stderr", ); } else { - seeds = yield* getPendingSeeds(session, fs, path, toml.seed.sqlPaths, workdir); + seeds = yield* getPendingSeeds(session, fs, path, seed.input.sqlPaths, workdir); } } @@ -216,16 +217,39 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn yield* output.raw(confirmSeedAll(seeds), "stderr"); } } else { - if (globals.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - "Do you want to create custom roles in the database cluster?", - true, + if (Option.isNone(preapproved)) { + const parts = [ + ...(globals.length > 0 ? ["create custom roles"] : []), + ...(pending.length > 0 ? [`apply ${plural(pending.length, "migration")}`] : []), + ...(seeds.length > 0 ? [`seed ${plural(seeds.length, "file")}`] : []), + ]; + const seedDetail = seed.detail(seeds.map((s) => s.path)); + yield* requireTargetMutationConsent( + { conn, isLocal }, + { + action: capitalize(joinParts(parts)), + details: [ + ...(globals.length > 0 ? [`Create custom roles from ${bold(globals[0]!)}`] : []), + ...(pending.length > 0 + ? [ + `Apply these migrations:\n${confirmPushAll(pending.map((p) => path.basename(p))).trimEnd()}`, + ...(includeVault && vaultSecrets.some((secret) => secret.resolved) + ? ["Update vault secrets from config.toml"] + : []), + ] + : []), + ...(seeds.length > 0 + ? [ + `Seed these files:\n${confirmSeedAll(seeds).trimEnd()}`, + ...(seedDetail === undefined ? [] : [capitalize(seedDetail)]), + ] + : []), + ], + }, ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } + } + + if (globals.length > 0) { yield* seedGlobals( session, fs, @@ -236,15 +260,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (pending.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } if (includeVault) { yield* upsertVaultSecrets(session, vaultSecrets); } @@ -254,15 +269,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (seeds.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } yield* seedData(session, fs, workdir, path, seeds, applyError); } else if (includeSeed) { yield* output.raw("Seed files are up to date.\n", "stderr"); diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts deleted file mode 100644 index 8622d8e393..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Pure flag-presence helpers for the `--db-url / --linked / --local` target selection shared by - * `db lint`, `db advisors`, and `test db`. - * - * Effect CLI's parsed flags carry no "was this explicitly set" bit, so this re-derives it from - * raw `process.argv`. The mutually-exclusive-group error's "were all set" list is alphabetically - * sorted regardless of each command's own flag registration order, which `setFlags` always - * returns. A value-consuming flag given in space form (`--flag value`) has its value token - * skipped during the scan, so e.g. `--schema --linked` does not misdetect `--linked` as changed. - */ - -import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; - -export type DbConnType = "db-url" | "linked" | "local"; - -export interface DbTargetSelection { - /** Alphabetically-sorted list of explicitly-set selector flags ("db-url", "linked", "local"). */ - readonly setFlags: ReadonlyArray; - /** - * Changed-first selection: db-url > local > linked (if changed) > undefined (→ local - * default). `undefined` means no selector was explicitly set; callers default to "local". - */ - readonly connType: DbConnType | undefined; -} - -/** - * Long-form flags that consume the next token as their value in space-separated form - * (`--flag value`), so the target-selector scan skips it. Also consulted by - * `extractChangedFlagNames` across every command's argv, so this lists every value-consuming - * flag declared anywhere under `commands/`; a missing entry can misdetect a flag's value token - * as a global flag's long name. `db-target-flags.unit.test.ts` enforces coverage for - * directly-declared flags; flags built through a name-parameterized helper are listed by hand. - */ -export const VALUE_CONSUMING_LONG_FLAGS = new Set([ - // db-family command flags - "db-url", - "password", // db push/pull/dump/remote (short -p) - "sql-paths", - "schema", - "level", - "fail-on", - "type", - // migration/db credential flag; consumes the next token as its value. - "password", - // inspect report flag - "output-dir", - // storage cp command flags (Flag.String / Flag.Int) - "cache-control", - "content-type", - "jobs", - // global flags (Flag.String / Flag.Literals) - "output", - "output-format", - "profile", - "workdir", - "network-id", - "dns-resolver", - "agent", - // `--log-level` is not listed: an argv giving it a flag-shaped value fails the real parse - // before any scanner here runs, so nothing can mis-consume around it. `--completions` prints - // and exits before any handler runs, so these scans never see it either. - // Every other value-consuming flag declared directly across commands/ (see the doc comment - // above). - "add-domains", - "algorithm", - "attribute-mapping-file", - "auth", - "config", - "custom-hostname", - "db-allow-cidr", - "db-password", - "db-unban-ip", - "desired-subdomain", - "diff-engine", - "domains", - "env-file", - "exclude", - "exp", - "exposure", - "file", - "from", - "from-backup", - "git-branch", - "import-map", - "inspect-mode", - "instances", - "kind", - "lang", - "last", - "metadata-file", - "metadata-url", - "name", - "name-id-format", - "notify-url", - "org-id", - "override-name", - "payload", - "plan", - "postgres-engine", - "project-id", - "project-ref", - "query-timeout", - "region", - "release-channel", - "remote-label", - "remove-domains", - "role", - "runtime", - "size", - "source", - "status", - "sub", - ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, - "tail", - "template", - "timestamp", - "to", - "token", - "valid-for", - "version", - // Declared through a name-parameterized helper, invisible to the static scan (see the doc - // comment above): `issue.command.ts`'s `issueOptionalTextFlag`. (The `stringSliceFlag`-built - // names — domains, add-domains, remove-domains, config, exclude, override-name, db-unban-ip, - // db-allow-cidr — are already listed in the sections above.) - "additional-context", - "area", - "command", - "actual-output", - "expected-behavior", - "reproduce", - "crash-report-id", - "docker-services", - "problem", - "proposed-solution", - "alternatives", - "link", - "issue-type", - "improvement", - // experimental stack flags - "capability", - "service", - "since", - "stack", - "stack-id", - "preparation", -]); - -/** - * Short flags (without `-` prefix) that consume the next token as their value. - * Only single-character short flags need to be listed here. - */ -export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ - "s", // --schema / -s - "o", // --output / -o - "p", // --password / -p (migration list, db push/pull/dump/remote) - "j", // --jobs / -j (storage cp) - "f", // --file / -f (db dump/diff/query, db schema declarative sync) - "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) - "x", // --exclude / -x (start, db dump) -]); - -/** - * Detects which of `--linked` / `--local` were explicitly set on the command line, for `seed - * buckets` and `storage ls/cp/mv/rm`'s mutually-exclusive-flag error message. - * - * Effect CLI's parsed flags carry no "was this set" bit, so this re-derives it from raw argv, - * skipping value tokens of space-separated value-consuming flags to avoid false positives. The - * negation form (`--no-linked`/`--no-local`) counts as changed. Returned alphabetically sorted - * (`["linked", "local"]`) so the rendered conflict string matches exactly. - */ -export function changedLinkedLocalFlags(args: ReadonlyArray): ReadonlyArray { - let linked = false; - let local = false; - let skipNext = false; - - for (const token of args) { - if (skipNext) { - skipNext = false; - continue; - } - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - if (name === "linked" || name === "no-linked") { - linked = true; - continue; - } - if (name === "local" || name === "no-local") { - local = true; - continue; - } - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) skipNext = true; - continue; - } - - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(token.charAt(1))) { - skipNext = true; - } - } - } - - const setFlags: Array = []; - if (linked) setFlags.push("linked"); - if (local) setFlags.push("local"); - return setFlags; -} - -/** - * Resolves the DB target selection from raw CLI args with a single left-to-right pass, skipping - * value tokens that follow space-separated value-consuming flags to avoid false-positive - * detection. - * - * `setFlags` is built in the fixed alphabetical order `["db-url", "linked", "local"]` so the - * rendered conflict string (`[db-url linked]`, `[linked local]`, …) matches exactly. - * - * `connType` follows Changed-first precedence: - * 1. `--db-url` if changed → "db-url" - * 2. `--local` if changed → "local" - * 3. `--linked` if changed → "linked" - * 4. none changed → `undefined` (callers default to "local") - */ -export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelection { - let dbUrlChanged = false; - let linkedChanged = false; - let localChanged = false; - - let skipNext = false; - for (const token of args) { - // A value-consuming flag consumes the next token as its value even when that token is "--". - // Only a "--" that is not a pending value acts as the end-of-options sentinel. - if (skipNext) { - skipNext = false; - continue; - } - - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - - // Check target selectors. - if (name === "db-url") { - dbUrlChanged = true; - // --db-url is a string flag: in space form the next token is the value. - if (isBare) skipNext = true; - continue; - } - if (name === "linked" || name === "no-linked") { - linkedChanged = true; - continue; - } - if (name === "local" || name === "no-local") { - localChanged = true; - continue; - } - - // Non-target long flag: skip its value token if value-consuming and bare. - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) { - skipNext = true; - } - continue; - } - - // Short flags: `-s`, `-o`, etc. - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - const shortName = token.charAt(1); - // `-s` bare (length === 2): next token is the value. - // `-svalue` (length > 2): value is attached, no skip needed. - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(shortName)) { - skipNext = true; - } - } - } - - const setFlags: Array = []; - if (dbUrlChanged) setFlags.push("db-url"); - if (linkedChanged) setFlags.push("linked"); - if (localChanged) setFlags.push("local"); - - let connType: DbConnType | undefined; - if (dbUrlChanged) { - connType = "db-url"; - } else if (localChanged) { - connType = "local"; - } else if (linkedChanged) { - connType = "linked"; - } - - return { setFlags, connType }; -} diff --git a/apps/cli/src/command-internal/db-target-flags.unit.test.ts b/apps/cli/src/command-internal/db-target-flags.unit.test.ts deleted file mode 100644 index 45d1013257..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; -import { fileURLToPath } from "node:url"; -import { - changedLinkedLocalFlags, - resolveDbTargetFlags, - VALUE_CONSUMING_LONG_FLAGS, - VALUE_CONSUMING_SHORT_FLAGS, -} from "./db-target-flags.ts"; - -describe("resolveDbTargetFlags", () => { - it("returns empty setFlags and undefined connType when no args", () => { - const result = resolveDbTargetFlags([]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("detects --linked as changed (connType='linked')", () => { - const result = resolveDbTargetFlags(["--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --linked=false as changed (Changed, not value)", () => { - const result = resolveDbTargetFlags(["db", "lint", "--linked=false"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --no-linked as changed (boolean negation is still Changed)", () => { - const result = resolveDbTargetFlags(["--no-linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --db-url as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("detects --db-url= as changed", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("--local=false --linked produces setFlags length 2 with alphabetical order [linked local]", () => { - const result = resolveDbTargetFlags(["--local=false", "--linked"]); - expect(result.setFlags).toEqual(["linked", "local"]); - expect(result.setFlags).toHaveLength(2); - expect(result.connType).toBe("local"); - }); - - it("--db-url=postgres://x --linked produces setFlags [db-url linked] with connType=db-url", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked"]); - expect(result.connType).toBe("db-url"); - }); - - it("tokens after bare -- are not scanned (end-of-options sentinel)", () => { - const result = resolveDbTargetFlags(["--", "--linked"]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("--db-url (key only, value as next arg) is still detected as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("setFlags order is always alphabetical [db-url, linked, local] regardless of argv order", () => { - const result = resolveDbTargetFlags(["--local", "--db-url=x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked", "local"]); - }); - - it("Changed-first precedence: db-url > local > linked", () => { - const all = resolveDbTargetFlags(["--db-url=x", "--linked", "--local"]); - expect(all.connType).toBe("db-url"); - - const localLinked = resolveDbTargetFlags(["--linked", "--local"]); - expect(localLinked.connType).toBe("local"); - - const linkedOnly = resolveDbTargetFlags(["--linked"]); - expect(linkedOnly.connType).toBe("linked"); - }); - - it("skips value token after bare --schema so --linked is not a false positive", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --level so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--level", "error", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema=value (attached form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["--schema=public", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -s (short for --schema)", () => { - const result = resolveDbTargetFlags(["-s", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-svalue (attached short form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["-spublic", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare --output so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--output", "json", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--output-dir does NOT mark --local as changed (value consumed)", () => { - const result = resolveDbTargetFlags(["--output-dir", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("--output-dir= (attached form) DOES mark --local as changed", () => { - const result = resolveDbTargetFlags(["--output-dir=./reports", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema -- --linked: -- consumed as schema value, --linked is a real flag (Go pflag parity)", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("bare -- with no pending skip still stops the scan", () => { - const result = resolveDbTargetFlags(["--linked", "--", "--local"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -p so --local is the password value, not a target", () => { - const result = resolveDbTargetFlags(["migration", "list", "-p", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --password so --linked is its value, not a flag", () => { - const result = resolveDbTargetFlags(["--password", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-ppwd (attached short password) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["-ppwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--password=pwd (attached long form) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["--password=pwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); -}); - -describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { - // `extractChangedFlagNames` relies on these two sets across every command, not just the - // db-target subset this file's other describe block covers — see the doc comment on - // `VALUE_CONSUMING_LONG_FLAGS` in `db-target-flags.ts`. This scan is static-source-based - // rather than importing every command module, so it can only see flag names declared as a - // literal string; a name passed through a helper function is registered by hand instead. - const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); - const INDIRECT_NAME_FILES = new Set(["issue.command.ts"]); - const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; - - const walk = Effect.fnUntraced(function* (dir: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const entries = yield* fs.readDirectory(dir, { recursive: true }); - return entries - .filter((entry) => entry.endsWith(".command.ts")) - .map((entry) => path.join(dir, entry)); - }); - - interface DeclaredFlag { - readonly file: string; - readonly name: string; - readonly alias: string | undefined; - } - - function extractDeclaredFlags(filePath: string, source: string): Array { - const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; - const calls = Array.from(source.matchAll(callRegex), (match) => ({ - index: match.index, - kind: match[1]!, - })); - - const declared: Array = []; - for (let i = 0; i < calls.length; i++) { - const current = calls[i]!; - if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; - - // Name declared as a literal string (e.g. `Flag.String("schema")`); a name passed as an - // identifier doesn't match and is silently skipped — see INDIRECT_NAME_FILES above. - const remainder = source.slice(current.index); - const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); - if (!nameMatch) continue; - - // The alias, if any, is somewhere in the `.pipe(...)` chain between - // this flag declaration and the next one. - const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; - const window = source.slice(current.index, windowEnd); - const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); - - declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); - } - return declared; - } - - it.effect( - "registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { - missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { - missing.push( - `-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`, - ); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - -describe("changedLinkedLocalFlags", () => { - it("returns nothing when neither selector is present", () => { - expect(changedLinkedLocalFlags(["seed", "buckets"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "ls", "ss:///"])).toEqual([]); - }); - - it("returns a single selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--local"])).toEqual(["local"]); - }); - - it("returns both selectors in cobra's sorted order when both are set", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("handles = forms", () => { - expect(changedLinkedLocalFlags(["--local=true", "--linked=false"])).toEqual([ - "linked", - "local", - ]); - }); - - it("treats the --no-* negation form as changed", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["storage", "ls", "--no-local"])).toEqual(["local"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("does not treat a value-consuming flag's value as a selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--workdir", "--linked"])).toEqual([]); - expect(changedLinkedLocalFlags(["-o", "--linked", "--local"])).toEqual(["local"]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--content-type", "--local", "a", "b"]), - ).toEqual([]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--cache-control", "--linked", "a", "b"]), - ).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "--local", "a", "b"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "-j", "--linked", "a", "b"])).toEqual([]); - }); - - it("still detects a selector after a value-consuming flag's value", () => { - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "5", "--local", "a", "b"])).toEqual([ - "local", - ]); - }); - - it("stops scanning at the -- terminator", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--", "--local", "--linked"])).toEqual([]); - }); - - it("detects selectors given after positional arguments", () => { - expect(changedLinkedLocalFlags(["storage", "rm", "ss:///b/x", "--local"])).toEqual(["local"]); - }); -}); diff --git a/apps/cli/src/command-internal/debug-logger.layer.ts b/apps/cli/src/command-internal/debug-logger.layer.ts index b0a1c75d43..a2de6fc90c 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.ts @@ -1,11 +1,11 @@ import { DateTime, Effect, Layer } from "effect"; import { DebugFlag } from "./global-flags.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; const pad = (n: number): string => String(n).padStart(2, "0"); -/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +/** Formats a timestamp as `YYYY/MM/DD HH:MM:SS`. */ function formatTimestamp(now: DateTime.DateTime): string { const local = DateTime.toParts(DateTime.setZone(now, DateTime.zoneMakeLocal())); return ( diff --git a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts index 846ae8b799..98592d656c 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts @@ -6,7 +6,7 @@ import { vi } from "vitest"; import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { DebugFlag } from "./global-flags.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; function makeLayer(debug: boolean) { return debugLoggerLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, debug))); @@ -44,7 +44,7 @@ describe("debugLoggerLayer", () => { ); }); - it.effect("http emits Go timestamp order and method/url format", () => { + it.effect("http emits timestamp, method and url in order", () => { const stderr = captureStderr(); const body = Effect.gen(function* () { const localTime = DateTime.makeZonedUnsafe( diff --git a/apps/cli/src/command-internal/detect-content-type.ts b/apps/cli/src/command-internal/detect-content-type.ts index 2c01986594..f6550503eb 100644 --- a/apps/cli/src/command-internal/detect-content-type.ts +++ b/apps/cli/src/command-internal/detect-content-type.ts @@ -1,6 +1,6 @@ /** * Content-type sniffer for the `seed buckets` upload path: Storage's `Content-Type` metadata is - * byte-driven, not extension-driven, matching Go's `net/http.DetectContentType` byte-for-byte so + * byte-driven, not extension-driven, so * stored metadata stays reproducible. Signature order matters: first match wins. */ diff --git a/apps/cli/src/command-internal/detect-content-type.unit.test.ts b/apps/cli/src/command-internal/detect-content-type.unit.test.ts index 70fc8d0483..8c93db206f 100644 --- a/apps/cli/src/command-internal/detect-content-type.unit.test.ts +++ b/apps/cli/src/command-internal/detect-content-type.unit.test.ts @@ -10,8 +10,6 @@ function bytes(s: string): Uint8Array { } describe("detectContentType", () => { - // Expected values were produced by running Go's `http.DetectContentType` over these same - // byte inputs. const corpus: ReadonlyArray = [ ["png", "\x89PNG\x0D\x0A\x1A\x0A\x00\x00", "image/png"], ["pdf", "%PDF-1.4\n...", "application/pdf"], @@ -37,7 +35,7 @@ describe("detectContentType", () => { ]; for (const [name, input, expected] of corpus) { - it(`matches Go http.DetectContentType for ${name}`, () => { + it(`detects the expected content type for ${name}`, () => { expect(detectContentType(bytes(input))).toBe(expected); }); } diff --git a/apps/cli/src/command-internal/diff-engine.ts b/apps/cli/src/command-internal/diff-engine.ts index e0ebc1b3a5..b6ff5b8430 100644 --- a/apps/cli/src/command-internal/diff-engine.ts +++ b/apps/cli/src/command-internal/diff-engine.ts @@ -2,34 +2,18 @@ // dependencies — unit-tested directly. export const schemaPathsTransitionWarning = - "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. Use `supabase db schema declarative sync` to compare declarative schema files.\n"; + "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. `supabase db schema declarative sync` reads declarative_schema_path (default supabase/schemas), not schema_paths: move those files there or point declarative_schema_path at them. Only migra (--use-migra, --diff-engine migra, or [experimental.pgdelta] enabled = false) still reads schema_paths, and only for local targets.\n"; /** - * Whether pg-delta is the active default engine: the resolved config flag - * (`[experimental.pgdelta].enabled`), the command's `--use-pg-delta` flag, or the - * `SUPABASE_EXPERIMENTAL_PG_DELTA` env var, whichever is set. - */ -export function shouldUsePgDelta(inputs: { - readonly configEnabled: boolean; - readonly usePgDeltaFlag: boolean; - readonly envEnabled: boolean; -}): boolean { - return inputs.configEnabled || inputs.usePgDeltaFlag || inputs.envEnabled; -} - -/** - * Reports whether `db diff` should run in pg-delta mode. An explicit `--use-migra`, - * `--use-pgadmin`, or `--use-pg-schema` is an authoritative rollback that clears pg-delta mode; - * `--use-migra` defaults to true, so only an explicit pass (`useMigraChanged`) counts as opting - * out. + * Reports whether `db diff` should run in pg-delta mode. A true `--use-migra` or `--use-pgadmin` + * is an authoritative rollback that clears pg-delta mode; `--use-migra=false` keeps the default. */ export function resolveDiffEngine(inputs: { - readonly useMigraChanged: boolean; + readonly useMigra: boolean; readonly usePgAdmin: boolean; - readonly usePgSchema: boolean; readonly pgDeltaDefault: boolean; }): boolean { - if (inputs.useMigraChanged || inputs.usePgAdmin || inputs.usePgSchema) { + if (inputs.useMigra || inputs.usePgAdmin) { return false; } return inputs.pgDeltaDefault; @@ -50,40 +34,3 @@ export function resolvePullDiffEngine(inputs: { } return inputs.pgDeltaDefault; } - -/** - * Parses a boolean env var: accepts exactly `1`, `t`, `T`, `TRUE`, `true`, `True` as true, and - * every other value (including unparseable strings and unset) as false. - */ -export function parseBoolEnv(raw: string | undefined): boolean { - switch (raw) { - case "1": - case "t": - case "T": - case "TRUE": - case "true": - case "True": - return true; - default: - return false; - } -} - -/** - * Resolves `db pull` declarative mode from raw argv: `--declarative` and the deprecated - * `--use-pg-delta` alias bind to the same outcome, so the last occurrence in argv wins (ORing - * the two would wrongly select declarative mode from either flag alone). A bare flag is `true`; - * `--flag=value` parses with {@link parseBoolEnv}'s true-set — a following space-separated token - * is never consumed as a value. Returns `undefined` when neither flag is present. - */ -export function resolveDeclarativeFromArgs(args: ReadonlyArray): boolean | undefined { - const FLAG_PATTERN = /^--(?:declarative|use-pg-delta)(?:=(.*))?$/u; - let result: boolean | undefined; - for (const arg of args) { - if (arg === "--") break; - const match = FLAG_PATTERN.exec(arg); - if (match === null) continue; - result = match[1] === undefined ? true : parseBoolEnv(match[1]); - } - return result; -} diff --git a/apps/cli/src/command-internal/diff-engine.unit.test.ts b/apps/cli/src/command-internal/diff-engine.unit.test.ts index 7f1a7998b9..6286e19af8 100644 --- a/apps/cli/src/command-internal/diff-engine.unit.test.ts +++ b/apps/cli/src/command-internal/diff-engine.unit.test.ts @@ -1,35 +1,11 @@ import { describe, expect, it } from "vitest"; -import { - parseBoolEnv, - resolveDeclarativeFromArgs, - resolveDiffEngine, - resolvePullDiffEngine, - shouldUsePgDelta, -} from "./diff-engine.ts"; - -describe("shouldUsePgDelta", () => { - it("is the OR of config, flag, and env", () => { - expect( - shouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: false }), - ).toBe(false); - expect( - shouldUsePgDelta({ configEnabled: true, usePgDeltaFlag: false, envEnabled: false }), - ).toBe(true); - expect( - shouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: true, envEnabled: false }), - ).toBe(true); - expect( - shouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: true }), - ).toBe(true); - }); -}); +import { resolveDiffEngine, resolvePullDiffEngine } from "./diff-engine.ts"; describe("resolveDiffEngine", () => { const base = { - useMigraChanged: false, + useMigra: false, usePgAdmin: false, - usePgSchema: false, pgDeltaDefault: true, }; @@ -39,16 +15,12 @@ describe("resolveDiffEngine", () => { }); it("an explicit --use-migra clears pg-delta mode", () => { - expect(resolveDiffEngine({ ...base, useMigraChanged: true })).toBe(false); + expect(resolveDiffEngine({ ...base, useMigra: true })).toBe(false); }); it("--use-pgadmin clears pg-delta mode", () => { expect(resolveDiffEngine({ ...base, usePgAdmin: true })).toBe(false); }); - - it("--use-pg-schema clears pg-delta mode", () => { - expect(resolveDiffEngine({ ...base, usePgSchema: true })).toBe(false); - }); }); describe("resolvePullDiffEngine", () => { @@ -86,49 +58,3 @@ describe("resolvePullDiffEngine", () => { ).toBe(false); }); }); - -describe("parseBoolEnv", () => { - it("accepts only strconv.ParseBool truthy strings", () => { - for (const v of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(parseBoolEnv(v)).toBe(true); - } - }); - - it("treats every other value (including unset) as false", () => { - for (const v of ["0", "f", "FALSE", "false", "yes", "on", "2", "", "TrUe"]) { - expect(parseBoolEnv(v)).toBe(false); - } - expect(parseBoolEnv(undefined)).toBe(false); - }); -}); - -describe("resolveDeclarativeFromArgs", () => { - it("returns undefined when neither flag is present", () => { - expect(resolveDeclarativeFromArgs(["db", "pull"])).toBeUndefined(); - expect(resolveDeclarativeFromArgs([])).toBeUndefined(); - }); - - it("treats a bare flag as true", () => { - expect(resolveDeclarativeFromArgs(["db", "pull", "--declarative"])).toBe(true); - expect(resolveDeclarativeFromArgs(["db", "pull", "--use-pg-delta"])).toBe(true); - }); - - it("parses an =value with strconv.ParseBool semantics", () => { - expect(resolveDeclarativeFromArgs(["--declarative=false"])).toBe(false); - expect(resolveDeclarativeFromArgs(["--declarative=true"])).toBe(true); - expect(resolveDeclarativeFromArgs(["--use-pg-delta=0"])).toBe(false); - expect(resolveDeclarativeFromArgs(["--use-pg-delta=1"])).toBe(true); - }); - - it("lets the last occurrence win across both flag names (pflag single-variable bind)", () => { - expect(resolveDeclarativeFromArgs(["--declarative", "--use-pg-delta=false"])).toBe(false); - expect(resolveDeclarativeFromArgs(["--use-pg-delta", "--declarative=false"])).toBe(false); - expect(resolveDeclarativeFromArgs(["--declarative=false", "--use-pg-delta"])).toBe(true); - expect(resolveDeclarativeFromArgs(["--use-pg-delta=false", "--declarative"])).toBe(true); - }); - - it("ignores tokens after the `--` argv terminator", () => { - expect(resolveDeclarativeFromArgs(["--declarative", "--", "--use-pg-delta=false"])).toBe(true); - expect(resolveDeclarativeFromArgs(["--", "--declarative"])).toBeUndefined(); - }); -}); diff --git a/apps/cli/src/command-internal/docker-ids.ts b/apps/cli/src/command-internal/docker-ids.ts index 7be84f785e..389576db84 100644 --- a/apps/cli/src/command-internal/docker-ids.ts +++ b/apps/cli/src/command-internal/docker-ids.ts @@ -4,46 +4,7 @@ * the local stack is running. */ -import type { Path } from "effect"; - -/** - * Resolves the local project id used to derive Docker resource names. - * - * Precedence: `SUPABASE_PROJECT_ID` env var, then config.toml's `project_id`, then - * `--project-ref` (when the command accepts one), then the working directory's basename. - */ -export function resolveLocalProjectId( - path: Path.Path, - envProjectId: string | undefined, - tomlProjectId: string | undefined, - workdir: string, - projectRefDefault?: string, -): string { - if (envProjectId !== undefined && envProjectId.length > 0) return envProjectId; - if (tomlProjectId !== undefined && tomlProjectId.length > 0) return tomlProjectId; - if (projectRefDefault !== undefined && projectRefDefault.length > 0) return projectRefDefault; - return path.basename(workdir); -} - -const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; -const MAX_PROJECT_ID_LENGTH = 40; - -function truncateText(text: string, maxLength: number) { - return text.length > maxLength ? text.slice(0, maxLength) : text; -} - -/** - * Sanitizes a project id for use in container/network names: replaces invalid character runs - * with `_`, strips leading `_.-`, and caps the result at 40 characters. - * - * Exported separately from {@link serviceContainerName} because a Docker label filter built - * from a project id must be sanitized the same way, or a `project_id` like `"my app"` won't - * match the label `start` wrote (see {@link cliProjectFilterValue}). - */ -export function sanitizeProjectId(src: string) { - const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); - return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); -} +import { sanitizeProjectId } from "../shared/config/project-id.ts"; /** `supabase__` — the naming scheme for local Docker resources. */ export function serviceContainerName(suffix: string, projectId: string): string { diff --git a/apps/cli/src/command-internal/docker-ids.unit.test.ts b/apps/cli/src/command-internal/docker-ids.unit.test.ts index cbea70ac5e..f4c6a2085e 100644 --- a/apps/cli/src/command-internal/docker-ids.unit.test.ts +++ b/apps/cli/src/command-internal/docker-ids.unit.test.ts @@ -1,54 +1,20 @@ -import { BunPath } from "@effect/platform-bun"; -import { describe, expect, it, layer } from "@effect/vitest"; -import { ConfigProvider, Effect, Path } from "effect"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { CLI_PROJECT_LABEL, cliProjectFilterValue, - resolveLocalProjectId, - sanitizeProjectId, serviceContainerIds, - localDbContainerId, localNetworkId, } from "./docker-ids.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; import { resolveDockerNetworkMode } from "../shared/functions/functions-docker.ts"; -import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; - -layer(BunPath.layer)("resolveLocalProjectId", (it) => { - it.effect("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(resolveLocalProjectId(path, "env-id", "toml-id", "/work/proj")).toBe("env-id"); - }), - ); - - it.effect("falls back to config.toml project_id when the env var is unset/empty", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(resolveLocalProjectId(path, undefined, "toml-id", "/work/proj")).toBe("toml-id"); - expect(resolveLocalProjectId(path, "", "toml-id", "/work/proj")).toBe("toml-id"); - }), - ); - - it.effect("falls back to the workdir basename when both env and config.toml are absent", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(resolveLocalProjectId(path, undefined, undefined, "/work/my-app")).toBe("my-app"); - expect(resolveLocalProjectId(path, undefined, "", "/work/my-app")).toBe("my-app"); - }), - ); - - it.effect("feeds the resolved id into the local db container name", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const id = resolveLocalProjectId(path, "env-id", undefined, "/work/proj"); - expect(localDbContainerId(id)).toBe("supabase_db_env-id"); - }), - ); -}); +import { supabaseEnvStringWithProjectFallback } from "./supabase-env.ts"; describe("serviceContainerIds", () => { - it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + it("returns the 13 service container ids in order", () => { expect(serviceContainerIds("my-app")).toEqual([ "supabase_kong_my-app", "supabase_auth_my-app", @@ -73,7 +39,7 @@ describe("serviceContainerIds", () => { }); describe("cliProjectFilterValue", () => { - it("returns the bare label when the project id is empty (Go's --all path)", () => { + it("returns the bare label when the project id is empty (the --all path)", () => { expect(cliProjectFilterValue("")).toBe(CLI_PROJECT_LABEL); }); @@ -88,94 +54,52 @@ describe("cliProjectFilterValue", () => { }); }); -describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallback (start/db start call shape)", () => { +describe("resolveDockerNetworkMode composed with supabaseEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; - const withShellEnv = (env: Readonly>) => - Effect.provide( - ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), + function resolve( + flagValue: string | undefined, + projectEnv: Record, + shell?: string, + ) { + return Effect.runSync( + withConfigEnv( + shell === undefined ? {} : { [KEY]: shell }, + Effect.map(supabaseEnvStringWithProjectFallback(KEY, projectEnv), (envNetworkId) => + resolveDockerNetworkMode({ explicit: flagValue, envNetworkId, projectId: "my-app" }), + ), + ), ); - - function resolve(flagValue: string | undefined, projectEnv: Record) { - return Effect.gen(function* () { - return resolveDockerNetworkMode({ - explicit: flagValue, - envOverride: yield* viperEnvStringWithProjectFallback(KEY, projectEnv), - projectId: "my-app", - }); - }); } - it.effect("prefers an explicit --network-id flag over everything else", () => - Effect.gen(function* () { - expect( - yield* resolve("flag-network", { [KEY]: "toml-network" }).pipe( - withShellEnv({ [KEY]: "env-network" }), - ), - ).toBe("flag-network"); - }), - ); - - it.effect("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => - Effect.gen(function* () { - expect(yield* resolve(undefined, {}).pipe(withShellEnv({ [KEY]: "shell-network" }))).toBe( - "shell-network", - ); - }), - ); - - it.effect( - "falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", - () => - Effect.gen(function* () { - expect(yield* resolve(undefined, { [KEY]: "project-network" }).pipe(withShellEnv({}))).toBe( - "project-network", - ); - }), - ); - - it.effect( - "prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", - () => - Effect.gen(function* () { - expect( - yield* resolve(undefined, { [KEY]: "project-network" }).pipe( - withShellEnv({ [KEY]: "shell-network" }), - ), - ).toBe("shell-network"); - }), - ); - - it.effect( - "falls back to the generated network name when the flag and env are all absent/empty", - () => - Effect.gen(function* () { - expect(yield* resolve(undefined, {}).pipe(withShellEnv({}))).toBe(localNetworkId("my-app")); - expect(yield* resolve("", {}).pipe(withShellEnv({}))).toBe(localNetworkId("my-app")); - }), - ); - - it.effect( - "an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", - () => - Effect.gen(function* () { - expect( - yield* resolve("", { [KEY]: "project-network" }).pipe( - withShellEnv({ [KEY]: "env-network" }), - ), - ).toBe(localNetworkId("my-app")); - }), - ); - - it.effect( - "treats an empty shell value as present (blocks the project value) and falls to generated", - () => - Effect.gen(function* () { - expect( - yield* resolve(undefined, { [KEY]: "project-network" }).pipe(withShellEnv({ [KEY]: "" })), - ).toBe(localNetworkId("my-app")); - }), - ); + it("prefers an explicit --network-id flag over everything else", () => { + expect(resolve("flag-network", { [KEY]: "toml-network" }, "env-network")).toBe("flag-network"); + }); + + it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { + expect(resolve(undefined, {}, "shell-network")).toBe("shell-network"); + }); + + it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); + }); + + it("prefers the shell value over the project .env value", () => { + expect(resolve(undefined, { [KEY]: "project-network" }, "shell-network")).toBe("shell-network"); + }); + + it("falls back to the generated network name when the flag and env are all absent/empty", () => { + expect(resolve(undefined, {})).toBe(localNetworkId("my-app")); + expect(resolve("", {})).toBe(localNetworkId("my-app")); + }); + + it("an explicit-but-empty --network-id= skips the env var entirely", () => { + expect(resolve("", { [KEY]: "project-network" }, "env-network")).toBe(localNetworkId("my-app")); + }); + + it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { + expect(resolve(undefined, { [KEY]: "project-network" }, "")).toBe(localNetworkId("my-app")); + }); }); describe("sanitizeProjectId", () => { diff --git a/apps/cli/src/command-internal/docker-image-resolve.ts b/apps/cli/src/command-internal/docker-image-resolve.ts index cb846f3007..bf1006e7f4 100644 --- a/apps/cli/src/command-internal/docker-image-resolve.ts +++ b/apps/cli/src/command-internal/docker-image-resolve.ts @@ -130,14 +130,14 @@ export function makeDockerImageResolver( Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { stdoutChunks.push(chunk); - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { stderrChunks.push(chunk); - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), @@ -250,9 +250,9 @@ export function makeDockerImageResolver( // one, so the two don't glue together. yield* Effect.sync(() => { if (!lastPullEndedWithNewline) { - globalThis.process.stderr.write("\n"); + process.stderr.write("\n"); } - globalThis.process.stderr.write(`Retrying after ${delay / 1000}s: ${candidate}\n`); + process.stderr.write(`Retrying after ${delay / 1000}s: ${candidate}\n`); }); yield* Effect.sleep(`${delay} millis`); } diff --git a/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts b/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts index 10eefc1256..3615514ab6 100644 --- a/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts +++ b/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts @@ -195,6 +195,36 @@ describe("makeDockerImageResolver", () => { }), ); + it.effect("pulls a slim image from ECR Public once GHCR keeps rate-limiting it", () => + Effect.gen(function* () { + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = (() => true) as typeof globalThis.process.stderr.write; + + try { + const digest = "sha256:8fb7ae7cd0d8121c460c8756d7f2791ce9a19c3331b46987456dfa4f8b45d701"; + const slim = `ghcr.io/supabase/cli/postgres:17.11.0.004-r1@${digest}`; + const mirror = `public.ecr.aws/supabase/cli/postgres:17.11.0.004-r1@${digest}`; + const throttled = { + exitCode: 1, + stderr: "Error response from daemon: toomanyrequests: retry-after: 173.687µs", + }; + const mock = mockSpawner([throttled, throttled, throttled, { exitCode: 0 }]); + const fiber = yield* makeDockerImageResolver(mock.spawner)(slim).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("4 seconds"); + yield* TestClock.adjust("8 seconds"); + const image = yield* Fiber.join(fiber); + + expect(mock.pulls).toEqual([slim, slim, slim, mirror]); + expect(image).toBe(mirror); + } finally { + globalThis.process.stderr.write = originalWrite; + } + }), + ); + it.effect( "does not inject a blank line before the banner when the child error is newline-terminated", () => diff --git a/apps/cli/src/command-internal/docker-lifecycle.ts b/apps/cli/src/command-internal/docker-lifecycle.ts index 07878356a6..aa5bc5c7c2 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.ts @@ -235,13 +235,77 @@ export const inspectContainerState = (spawner: Spawner, containerId: string) => }), ).pipe(Effect.withTracerEnabled(false), Effect.provideService(ChildTracePropagation, false)); -function parseContainerState(stdout: string): { +/** The most recent health-check probe Docker recorded for a container. */ +interface ContainerHealthProbe { + readonly exitCode: number; + readonly output: string; +} + +export interface ContainerState { readonly running: boolean; readonly status: string; readonly exitCode: number; readonly oomKilled: boolean; readonly health?: string; -} { + readonly lastHealthProbe?: ContainerHealthProbe; +} + +const HEALTH_PROBE_OUTPUT_MAX = 300; + +function parseLastHealthProbe(health: unknown): ContainerHealthProbe | undefined { + if (!isJsonRecord(health)) return undefined; + const log = health["Log"]; + if (!Array.isArray(log)) return undefined; + const last: unknown = log[log.length - 1]; + if (!isJsonRecord(last)) return undefined; + const exitCode = last["ExitCode"]; + const output = last["Output"]; + if (typeof exitCode !== "number" || typeof output !== "string") return undefined; + return { exitCode, output: output.replace(/\s+/gu, " ").trim() }; +} + +// runc reports a bad healthcheck command as a Go `exec.Error`, an `os.PathError`, or (before +// runc 1.1) wrapped in "exec user process caused". +const EXEC_IMAGE_FAULT_PATTERNS: ReadonlyArray = [ + /exec: \\?"[^"]*": (?:executable file not found in \$PATH|stat \S+: no such file or directory|permission denied)/u, + /(?:^|: )exec [^\s:]+: (?:no such file or directory|exec format error|permission denied)/u, + /exec user process caused:? \\?"?(?:no such file or directory|exec format error|permission denied)/u, +]; + +/** + * True when Docker could not launch the health check inside a running container. moby records an + * exec-start failure as probe exit code -1 with the runtime error as output; a probe timeout also + * uses -1 but its output never names the OCI runtime. Image faults in the healthcheck command are + * excluded: they are the image's problem, not the host's. + */ +export function healthCheckCannotExec(state: ContainerState): boolean { + const probe = state.lastHealthProbe; + if (!state.running || state.status !== "running" || state.health === "healthy") return false; + if (probe === undefined || probe.exitCode !== -1) return false; + const out = probe.output; + return ( + out.includes("OCI runtime exec failed") && + (out.includes("unable to start container process") || + out.includes("starting container process caused")) && + !EXEC_IMAGE_FAULT_PATTERNS.some((pattern) => pattern.test(out)) + ); +} + +/** The not-ready reason for an unhealthy container, naming its last health-check probe. */ +export function formatNotReadyReason(state: ContainerState): string { + const base = `container is not ready: ${state.health}`; + const probe = state.lastHealthProbe; + if (probe === undefined) return base; + const output = + probe.output.length > HEALTH_PROBE_OUTPUT_MAX + ? `${probe.output.slice(0, HEALTH_PROBE_OUTPUT_MAX - 1)}…` + : probe.output; + return output.length > 0 + ? `${base} (last health check exit code ${probe.exitCode}: ${output})` + : `${base} (last health check exit code ${probe.exitCode})`; +} + +function parseContainerState(stdout: string): ContainerState { const trimmed = stdout.trim(); let parsed: unknown; try { @@ -262,9 +326,15 @@ function parseContainerState(stdout: string): { const health = state["Health"]; const healthStatus = isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; - return healthStatus !== undefined - ? { running, status, exitCode, oomKilled, health: healthStatus } - : { running, status, exitCode, oomKilled }; + const lastHealthProbe = parseLastHealthProbe(health); + return { + running, + status, + exitCode, + oomKilled, + ...(healthStatus === undefined ? {} : { health: healthStatus }), + ...(lastHealthProbe === undefined ? {} : { lastHealthProbe }), + }; } function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { diff --git a/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts b/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts index fe41ca0d35..f6491f3792 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts @@ -2,14 +2,22 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; +import { + EXEC_FAILURE_OUTPUT, + PROBE_TIMEOUT_OUTPUT, + inspectedState, +} from "../../tests/helpers/docker-health.ts"; import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; import { DockerLifecycleInspectError, DockerLifecycleListError, + formatNotReadyReason, + healthCheckCannotExec, inspectContainerState, listContainerIdsAndNames, listContainersByLabel, listVolumesByLabel, + type ContainerState, } from "./docker-lifecycle.ts"; function mockSpawner( @@ -286,19 +294,16 @@ describe("inspectContainerState", () => { ); }); - it.live( - "treats a paused/restarting container as running, matching Go's boolean-based gate", - () => { - const mock = mockSpawner({ - stdout: JSON.stringify({ Status: "paused", Running: true, ExitCode: 0 }), - }); - return inspectContainerState(mock.spawner, "supabase_db_my-app").pipe( - Effect.map((state) => { - expect(state).toEqual({ running: true, status: "paused", exitCode: 0, oomKilled: false }); - }), - ); - }, - ); + it.live("treats a paused/restarting container as running", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ Status: "paused", Running: true, ExitCode: 0 }), + }); + return inspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused", exitCode: 0, oomKilled: false }); + }), + ); + }); it.live( "fails with DockerLifecycleInspectError, preserving the real stderr, when the container does not exist", @@ -371,6 +376,307 @@ describe("inspectContainerState", () => { }); }); +describe("inspectContainerState health probe", () => { + const inspectLastProbe = (stdout: string) => + inspectContainerState(mockSpawner({ stdout }).spawner, "supabase_db_my-app"); + + it.live("reads the exec-start failure Docker recorded for a probe it could not launch", () => + inspectLastProbe( + inspectedState({ + health: "unhealthy", + probes: [{ exitCode: -1, output: EXEC_FAILURE_OUTPUT }], + }), + ).pipe( + Effect.map((state) => { + expect(state.health).toBe("unhealthy"); + expect(state.lastHealthProbe).toEqual({ exitCode: -1, output: EXEC_FAILURE_OUTPUT }); + expect(healthCheckCannotExec(state)).toBe(true); + }), + ), + ); + + it.live("keeps only the most recent entry of Health.Log", () => + inspectLastProbe( + inspectedState({ + health: "unhealthy", + probes: [ + { exitCode: 1, output: "first" }, + { exitCode: 2, output: "second" }, + { exitCode: 3, output: "third" }, + ], + }), + ).pipe( + Effect.map((state) => { + expect(state.lastHealthProbe).toEqual({ exitCode: 3, output: "third" }); + }), + ), + ); + + it.live("collapses whitespace runs and newlines in the probe output", () => + inspectLastProbe( + inspectedState({ + health: "unhealthy", + probes: [{ exitCode: 1, output: " pg_isready:\n\tno response\r\n " }], + }), + ).pipe( + Effect.map((state) => { + expect(state.lastHealthProbe?.output).toBe("pg_isready: no response"); + }), + ), + ); + + it.live("keeps probe output of exactly 300 characters whole", () => { + const output = "x".repeat(300); + return inspectLastProbe( + inspectedState({ health: "unhealthy", probes: [{ exitCode: 1, output }] }), + ).pipe( + Effect.map((state) => { + expect(state.lastHealthProbe?.output).toBe(output); + expect(formatNotReadyReason(state)).toContain(`: ${output})`); + }), + ); + }); + + it.live("keeps the full probe output and cuts only the rendered reason at 300 characters", () => + inspectLastProbe( + inspectedState({ + health: "unhealthy", + probes: [{ exitCode: 1, output: "x".repeat(301) }], + }), + ).pipe( + Effect.map((state) => { + expect(state.lastHealthProbe?.output).toBe("x".repeat(301)); + expect(formatNotReadyReason(state)).toContain(`: ${"x".repeat(299)}…)`); + }), + ), + ); + + const validProbe = { Start: "s", End: "e", ExitCode: 1, Output: "ok" }; + const malformedLogs: ReadonlyArray]> = [ + ["has no Log", { Status: "unhealthy" }], + ["has a Log that is not an array", { Status: "unhealthy", Log: "oops" }], + ["has an empty Log", { Status: "unhealthy", Log: [] }], + ["ends in an entry that is not an object", { Status: "unhealthy", Log: [validProbe, null] }], + ["ends in an entry with a string ExitCode", { Status: "unhealthy", Log: [{ ExitCode: "-1" }] }], + [ + "ends in an entry with no Output", + { Status: "unhealthy", Log: [validProbe, { ExitCode: -1 }] }, + ], + [ + "ends in an entry with a non-string Output", + { Status: "unhealthy", Log: [{ ExitCode: -1, Output: 7 }] }, + ], + ]; + for (const [scenario, health] of malformedLogs) { + it.live(`reports no probe when Health ${scenario}`, () => + inspectLastProbe(JSON.stringify({ Status: "running", Running: true, Health: health })).pipe( + Effect.map((state) => { + expect(state).toEqual({ + running: true, + status: "running", + exitCode: 0, + oomKilled: false, + health: "unhealthy", + }); + expect(state).not.toHaveProperty("lastHealthProbe"); + }), + ), + ); + } +}); + +describe("healthCheckCannotExec", () => { + const OLDER_RUNC_EXEC_FAILURE = + "OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: process_linux.go:103: executing setns process caused: exit status 1: unknown"; + + const state = (overrides: Partial = {}): ContainerState => ({ + running: true, + status: "running", + exitCode: 0, + oomKilled: false, + health: "unhealthy", + lastHealthProbe: { exitCode: -1, output: EXEC_FAILURE_OUTPUT }, + ...overrides, + }); + const probe = (exitCode: number, output: string) => ({ + lastHealthProbe: { exitCode, output }, + }); + const imageFaultPastTheDisplayedReason = `OCI runtime exec failed: exec failed: unable to start container process: ${"x".repeat(300)} exec: "/healthcheck.sh": permission denied: unknown`; + + const accepted: ReadonlyArray = [ + ["the exec-start failure Docker records on a sandboxed host", state()], + [ + "the same failure while Docker still reports the health as starting", + state({ health: "starting" }), + ], + ["the older runc wording of the same failure", state(probe(-1, OLDER_RUNC_EXEC_FAILURE))], + [ + "a cgroup path the host never created", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: error adding pid 123 to cgroups: failed to write 123: openat2 /sys/fs/cgroup/docker/abc/cgroup.procs: no such file or directory: unknown", + ), + ), + ], + [ + "a namespace file the host never exposed", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: error executing setns process: open /proc/123/ns/mnt: no such file or directory: unknown", + ), + ), + ], + [ + "runc unable to re-exec itself to enter the container", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: error executing setns process: fork/exec /proc/self/exe: permission denied: unknown", + ), + ), + ], + ]; + for (const [scenario, input] of accepted) { + it(`detects ${scenario}`, () => { + expect(healthCheckCannotExec(input)).toBe(true); + }); + } + + const rejected: ReadonlyArray = [ + ["a probe that outlived its timeout", state(probe(-1, PROBE_TIMEOUT_OUTPUT))], + [ + "a missing healthcheck binary", + state( + probe( + -1, + 'OCI runtime exec failed: exec failed: unable to start container process: exec: "pg_isready": executable file not found in $PATH: unknown', + ), + ), + ], + [ + "a healthcheck binary path that does not exist", + state( + probe( + -1, + 'OCI runtime exec failed: exec failed: unable to start container process: exec: "/usr/bin/pg_isready": stat /usr/bin/pg_isready: no such file or directory: unknown', + ), + ), + ], + [ + "a healthcheck binary built for another architecture", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: exec /usr/bin/pg_isready: exec format error: unknown", + ), + ), + ], + [ + "a healthcheck script that is not executable", + state( + probe( + -1, + 'OCI runtime exec failed: exec failed: unable to start container process: exec: "/healthcheck.sh": permission denied: unknown', + ), + ), + ], + [ + "a healthcheck script the kernel cannot open", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: exec /healthcheck.sh: permission denied: unknown", + ), + ), + ], + [ + "a healthcheck script whose shebang interpreter is missing", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: unable to start container process: exec /healthcheck.sh: no such file or directory: unknown", + ), + ), + ], + [ + "an older runc reporting a healthcheck binary built for another architecture", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec user process caused: exec format error: unknown", + ), + ), + ], + [ + "an older runc reporting a missing shebang interpreter", + state( + probe( + -1, + 'OCI runtime exec failed: exec failed: container_linux.go:349: starting container process caused "exec user process caused \\"no such file or directory\\"": unknown', + ), + ), + ], + [ + "an image fault that only appears after the first 300 characters", + state(probe(-1, imageFaultPastTheDisplayedReason)), + ], + [ + "an exec into a stopped container", + state( + probe( + -1, + "OCI runtime exec failed: exec failed: cannot exec in a stopped container: unknown", + ), + ), + ], + ["a stopped container that once failed to exec", state({ running: false, status: "exited" })], + ["a restarting container", state({ status: "restarting" })], + ["a container Docker already reports healthy", state({ health: "healthy" })], + ["a probe that failed with an ordinary exit code", state(probe(1, EXEC_FAILURE_OUTPUT))], + ["a container with no recorded probe", state({ lastHealthProbe: undefined })], + ]; + for (const [scenario, input] of rejected) { + it(`does not mistake ${scenario} for an exec failure`, () => { + expect(healthCheckCannotExec(input)).toBe(false); + }); + } +}); + +describe("formatNotReadyReason", () => { + const unhealthy: ContainerState = { + running: true, + status: "running", + exitCode: 0, + oomKilled: false, + health: "unhealthy", + }; + + it("is the bare health status when Docker recorded no probe", () => { + expect(formatNotReadyReason({ ...unhealthy, health: "starting" })).toBe( + "container is not ready: starting", + ); + }); + + it("appends the last probe's exit code and output", () => { + expect( + formatNotReadyReason({ + ...unhealthy, + lastHealthProbe: { exitCode: -1, output: PROBE_TIMEOUT_OUTPUT }, + }), + ).toBe( + "container is not ready: unhealthy (last health check exit code -1: Health check exceeded timeout (2s))", + ); + }); + + it("appends only the exit code when the probe printed nothing", () => { + expect( + formatNotReadyReason({ ...unhealthy, lastHealthProbe: { exitCode: 1, output: "" } }), + ).toBe("container is not ready: unhealthy (last health check exit code 1)"); + }); +}); + describe("listVolumesByLabel", () => { it.live("returns volume names for a successful listing", () => { const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); diff --git a/apps/cli/src/command-internal/docker-registry.ts b/apps/cli/src/command-internal/docker-registry.ts index 186b825de3..47df502b4f 100644 --- a/apps/cli/src/command-internal/docker-registry.ts +++ b/apps/cli/src/command-internal/docker-registry.ts @@ -3,9 +3,10 @@ * overrides the default ECR mirror; `docker.io` returns the image unchanged, and any other value * rewrites it to `/supabase/`. Callers that can retry pulls should * use {@link getRegistryImageUrlCandidates} instead, which falls back through GHCR and the - * source image. Slim images ({@link isSlimImageRef}) skip every rewrite — there's no mirror to - * redirect them to. + * source image. Slim images ({@link isSlimImageRef}) skip these rewrites; their only fallback is + * the ECR Public mirror at the same tag and digest. */ +import { slimImageMirrors } from "@supabase/stack/internal/artifacts"; import { Config, ConfigProvider, Effect, Option } from "effect"; import { isSlimImageRef } from "../shared/services/slim-images.ts"; @@ -67,7 +68,7 @@ export function getRegistryImageUrlCandidates( projectEnvValues?: Readonly>, ): Effect.Effect, Config.ConfigError> { if (isSlimImageRef(imageName)) { - return Effect.succeed([imageName]); + return Effect.succeed([imageName, ...slimImageMirrors(imageName)]); } return registryOverride(projectEnvValues).pipe( diff --git a/apps/cli/src/command-internal/docker-registry.unit.test.ts b/apps/cli/src/command-internal/docker-registry.unit.test.ts index a1b857206a..efe631df6d 100644 --- a/apps/cli/src/command-internal/docker-registry.unit.test.ts +++ b/apps/cli/src/command-internal/docker-registry.unit.test.ts @@ -81,7 +81,7 @@ describe("getRegistryImageUrl", () => { ]); }); - it("honors a projectEnvValues (dotenv)-only registry override, matching Go's post-Load os.Getenv", () => { + it("honors a projectEnvValues (dotenv)-only registry override", () => { expect( resolveImage(undefined, "supabase/pg_prove:3.36", { SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", @@ -102,12 +102,14 @@ describe("getRegistryImageUrl", () => { ).toBe("merged.example/supabase/pg_prove:3.36"); }); - // Published only under ghcr.io/supabase/cli; rewriting by last path segment would misroute it. + // Published under ghcr.io/supabase/cli; rewriting by last path segment would misroute it. const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + const SLIM_IMAGE_MIRROR = "public.ecr.aws/supabase/cli/postgres:17.6.1.165"; it("leaves a slim image unrewritten, whatever the registry override says", () => { for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { expect(resolveImage(registry, SLIM_IMAGE)).toBe(SLIM_IMAGE); + expect(resolveImage(registry, SLIM_IMAGE_MIRROR)).toBe(SLIM_IMAGE_MIRROR); } expect( resolveImage(undefined, SLIM_IMAGE, { @@ -116,15 +118,15 @@ describe("getRegistryImageUrl", () => { ).toBe(SLIM_IMAGE); }); - it("plans a single pull candidate for a slim image", () => { + it("plans the slim image, then its ECR Public mirror, whatever the registry override says", () => { for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { - expect(resolveCandidates(registry, SLIM_IMAGE)).toEqual([SLIM_IMAGE]); + expect(resolveCandidates(registry, SLIM_IMAGE)).toEqual([SLIM_IMAGE, SLIM_IMAGE_MIRROR]); } expect( resolveCandidates(undefined, SLIM_IMAGE, { SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", }), - ).toEqual([SLIM_IMAGE]); + ).toEqual([SLIM_IMAGE, SLIM_IMAGE_MIRROR]); }); it("still rewrites the non-slim ghcr.io/supabase namespace", () => { diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index a37893b1fd..c4f81138a3 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -89,7 +89,7 @@ function parsePrunedNames(stdout: string): ReadonlyArray { const reportPruned = (debug: boolean, label: string, stdout: string) => Effect.sync(() => { if (!debug) return; - globalThis.process.stderr.write(`${label} [${parsePrunedNames(stdout).join(" ")}]\n`); + process.stderr.write(`${label} [${parsePrunedNames(stdout).join(" ")}]\n`); }); /** @@ -128,6 +128,36 @@ const pruneFailureMessage = (kind: string, stderr: string) => { return detail.length > 0 ? `failed to prune ${kind}: ${detail}` : `failed to prune ${kind}`; }; +/** Prunes unused networks carrying `filterValue`; networks with attached containers are kept. */ +export const pruneProjectNetworks = ( + spawner: Spawner, + filterValue: string, + debug: boolean, +): Effect.Effect => + Effect.gen(function* () { + const networkPrune = yield* pruneWaitingForConcurrentPrune(spawner, [ + "network", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new DockerRemoveAllNetworkPruneError({ + message: `failed to prune networks: ${describeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPrune.exitCode !== 0) { + return yield* new DockerRemoveAllNetworkPruneError({ + message: pruneFailureMessage("networks", networkPrune.stderr), + }); + } + // Established output text: singular "network", unlike the container/volume reports. + yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); + }); + /** Every failure {@link dockerRemoveAll} can produce. */ export type DockerRemoveAllError = | DockerRemoveAllListError @@ -246,25 +276,5 @@ export const dockerRemoveAll = ( yield* reportPruned(debug, "Pruned volumes:", volumePrune.stdout); } - const networkPrune = yield* pruneWaitingForConcurrentPrune(spawner, [ - "network", - "prune", - "--force", - "--filter", - `label=${filterValue}`, - ]).pipe( - Effect.mapError( - (cause) => - new DockerRemoveAllNetworkPruneError({ - message: `failed to prune networks: ${describeContainerCliFailure(cause)}`, - }), - ), - ); - if (networkPrune.exitCode !== 0) { - return yield* new DockerRemoveAllNetworkPruneError({ - message: pruneFailureMessage("networks", networkPrune.stderr), - }); - } - // Established output text: singular "network", unlike the container/volume reports. - yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); + yield* pruneProjectNetworks(spawner, filterValue, debug); }); diff --git a/apps/cli/src/command-internal/docker-run.args.unit.test.ts b/apps/cli/src/command-internal/docker-run.args.unit.test.ts index f38831461b..f448a9bc43 100644 --- a/apps/cli/src/command-internal/docker-run.args.unit.test.ts +++ b/apps/cli/src/command-internal/docker-run.args.unit.test.ts @@ -26,7 +26,7 @@ describe("applyBitbucketDockerFilter", () => { expect(applyBitbucketDockerFilter(pgDelta, false)).toBe(pgDelta); }); - test("drops named-volume binds and clears security-opt under Bitbucket (Go DockerStart)", () => { + test("drops named-volume binds and clears security-opt under Bitbucket", () => { const filtered = applyBitbucketDockerFilter(pgDelta, true); // Named Deno-cache volume dropped; the /repo:/workspace bind mount kept. expect(filtered.binds).toEqual(["/repo:/workspace"]); @@ -35,7 +35,7 @@ describe("applyBitbucketDockerFilter", () => { }); describe("buildDockerArgs", () => { - test("assembles run args in Go-parity order for a named network", () => { + test("assembles run args in order for a named network", () => { expect(buildDockerArgs(base)).toEqual([ "run", "--rm", @@ -114,7 +114,7 @@ describe("buildDockerArgs", () => { expect(buildDockerArgs(base)).not.toContain("--label"); }); - test("emits --label k=v for each entry, before the image (Go's DockerStart project labels)", () => { + test("emits --label k=v for each entry, before the image", () => { const args = buildDockerArgs({ ...base, labels: { diff --git a/apps/cli/src/command-internal/docker-run.layer.ts b/apps/cli/src/command-internal/docker-run.layer.ts index 535238b569..fb3edc8f1c 100644 --- a/apps/cli/src/command-internal/docker-run.layer.ts +++ b/apps/cli/src/command-internal/docker-run.layer.ts @@ -89,7 +89,7 @@ export const dockerRunLayer: Layer.Layer { - it("detects the docker/podman daemon-down CLI messages (Go's IsErrConnectionFailed)", () => { + it("detects the docker/podman daemon-down CLI messages", () => { expect( isDockerDaemonUnreachable( "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", @@ -35,7 +35,7 @@ describe("isDockerDaemonUnreachable", () => { expect(isDockerDaemonUnreachable("")).toBe(false); }); - it("exposes Go's install hint verbatim", () => { + it("exposes the install hint verbatim", () => { expect(SUGGEST_DOCKER_INSTALL).toContain("https://docs.docker.com/desktop"); }); }); diff --git a/apps/cli/src/command-internal/go-duration.ts b/apps/cli/src/command-internal/duration.ts similarity index 91% rename from apps/cli/src/command-internal/go-duration.ts rename to apps/cli/src/command-internal/duration.ts index f9ab1f3526..0e11cd8fc7 100644 --- a/apps/cli/src/command-internal/go-duration.ts +++ b/apps/cli/src/command-internal/duration.ts @@ -1,6 +1,5 @@ /** - * Duration string parsing and formatting matching Go's `time.ParseDuration()`/`Duration.String()` - * grammar and rounding — the syntax the hosted API and container env vars expect. + * Duration string parsing and formatting in the `1h30m0s` grammar with nanosecond rounding — the syntax the hosted API and container env vars expect. * * Several `config.toml` duration fields (e.g. `auth.sessions.timebox = "1h"`) are stored as the * raw string, but a container's env var is built from the parsed-then-reserialized value, which @@ -15,7 +14,7 @@ const NS_PER_HOUR = 60 * NS_PER_MINUTE; const NS_PER_MS = 1_000_000; const NS_PER_US = 1_000; -// `BigInt` constants for `parseGoDuration`'s accumulator: near the int64 nanosecond ceiling +// `BigInt` constants for `parseDuration`'s accumulator: near the int64 nanosecond ceiling // (~9.223e18, past `Number.MAX_SAFE_INTEGER`), a `number` accumulator would silently round a // legitimate boundary value into a false overflow or vice versa. const NS_PER_SECOND_BIG = 1_000_000_000n; @@ -34,7 +33,7 @@ const MAX_INT64_NS = 9223372036854775807n; const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; /** - * Parses a Go-style duration string to nanoseconds: a possibly-signed sequence of decimal + * Parses a duration string to nanoseconds: a possibly-signed sequence of decimal * numbers, each with a unit suffix (`"ns"`, `"us"`/`"µs"`/`"μs"`, `"ms"`, `"s"`, `"m"`, `"h"`), e.g. * `"5s"`, `"1h30m"`, `"300ms"`. Throws on invalid input, including an overflowing magnitude. * @@ -43,7 +42,7 @@ const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; * ceiling, but every real config duration is nowhere near it, and the overflow check itself * doesn't depend on `number` precision. */ -export function parseGoDuration(value: string): number { +export function parseDuration(value: string): number { const orig = value; let s = value; let neg = false; @@ -139,11 +138,11 @@ export function parseGoDuration(value: string): number { } /** - * Formats nanoseconds as a Go-style duration string. `0` formats as `"0s"`; otherwise only the + * Formats nanoseconds as a duration string. `0` formats as `"0s"`; otherwise only the * needed units show, with minutes/seconds always trailing an hours component (`"1h0m0s"`), and a * sub-second remainder as a fraction of its largest applicable unit (`"1.5s"`, `"300ms"`). */ -export function formatGoDuration(nanoseconds: number): string { +export function formatDuration(nanoseconds: number): string { if (nanoseconds === 0) return "0s"; let ns = nanoseconds; @@ -202,10 +201,10 @@ function formatFraction(totalNs: number, unitNs: number): string { /** * Seconds form of a `Db.HealthTimeout` duration string (`"2m"` default). Throws on a malformed - * value, matching {@link parseGoDuration}; the caller wraps that into a typed config-load-failure + * value, matching {@link parseDuration}; the caller wraps that into a typed config-load-failure * error so rollback/cleanup still fires. A degenerate value like `"0s"` isn't special-cased: it * means exactly one immediate health probe with no wait, not a 30s fallback. */ export function resolveHealthTimeoutSeconds(healthTimeout: string): number { - return Math.trunc(parseGoDuration(healthTimeout) / 1_000_000_000); + return Math.trunc(parseDuration(healthTimeout) / 1_000_000_000); } diff --git a/apps/cli/src/command-internal/duration.unit.test.ts b/apps/cli/src/command-internal/duration.unit.test.ts new file mode 100644 index 0000000000..c1390e67b5 --- /dev/null +++ b/apps/cli/src/command-internal/duration.unit.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { formatDuration, parseDuration } from "./duration.ts"; + +describe("parseDuration", () => { + it("parses a single unit", () => { + expect(parseDuration("5s")).toBe(5_000_000_000); + expect(parseDuration("300ms")).toBe(300_000_000); + expect(parseDuration("1h")).toBe(3_600_000_000_000); + }); + + it("parses multiple units concatenated", () => { + expect(parseDuration("1h30m")).toBe(5_400_000_000_000); + }); + + it("parses a fractional value", () => { + expect(parseDuration("1.5s")).toBe(1_500_000_000); + }); + + it("parses a signed value", () => { + expect(parseDuration("-5s")).toBe(-5_000_000_000); + expect(parseDuration("+5s")).toBe(5_000_000_000); + }); + + it('treats a bare "0" as zero regardless of sign', () => { + expect(parseDuration("0")).toBe(0); + }); + + it("rejects an empty string", () => { + expect(() => parseDuration("")).toThrow('time: invalid duration ""'); + }); + + it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( + 'rejects a bare unit with no preceding digit ("%s")', + (input) => { + expect(() => parseDuration(input)).toThrow(`time: invalid duration "${input}"`); + }, + ); + + it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { + expect(parseDuration(`1${unit}`)).toBe(1_000); + }); + + it('rejects a bare unit following a valid unit ("1hs")', () => { + expect(() => parseDuration("1hs")).toThrow('time: invalid duration "1hs"'); + }); + + it('rejects a negative bare unit ("-s")', () => { + expect(() => parseDuration("-s")).toThrow('time: invalid duration "-s"'); + }); + + it.each([".s", ".", "-.", "+.", ".h"])('rejects a lone "." with no digits ("%s")', (input) => { + expect(() => parseDuration(input)).toThrow(`time: invalid duration "${input}"`); + }); + + it('accepts a fraction-only value with no leading digit (".5s")', () => { + expect(parseDuration(".5s")).toBe(500_000_000); + }); + + it("rejects a number with no unit", () => { + expect(() => parseDuration("5")).toThrow('time: missing unit in duration "5"'); + }); + + it("rejects an unknown unit", () => { + expect(() => parseDuration("5x")).toThrow('time: unknown unit in duration "5x"'); + }); + + it('truncates a sub-nanosecond fraction instead of rounding ("0.5ns")', () => { + expect(parseDuration("0.5ns")).toBe(0); + }); + + it('truncates a sub-nanosecond fraction instead of rounding ("1.9ns")', () => { + expect(parseDuration("1.9ns")).toBe(1); + }); + + it('rounds a long fractional remainder up ("0.999999999999999999s")', () => { + expect(parseDuration("0.999999999999999999s")).toBe(1_000_000_000); + }); + + it('rejects a duration that overflows math.MaxInt64 nanoseconds ("2562048h")', () => { + expect(() => parseDuration("2562048h")).toThrow('time: invalid duration "2562048h"'); + }); + + it("accepts the maximum parseable duration without overflowing", () => { + expect(() => parseDuration("2562047h47m16.854775807s")).not.toThrow(); + }); + + it('rejects a duration exactly 1ns past the int64 maximum ("9223372036854775808ns")', () => { + expect(() => parseDuration("9223372036854775808ns")).toThrow( + 'time: invalid duration "9223372036854775808ns"', + ); + }); + + it("accepts a duration exactly at the int64 maximum in nanoseconds", () => { + expect(() => parseDuration("9223372036854775807ns")).not.toThrow(); + }); + + it("accepts the minimum representable negative duration (int64 minimum ns)", () => { + expect(parseDuration("-9223372036854775808ns")).toBe(-9223372036854775808); + }); + + it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { + expect(parseDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); + }); + + it("rejects a duration 1ns past math.MinInt64", () => { + expect(() => parseDuration("-9223372036854775809ns")).toThrow( + 'time: invalid duration "-9223372036854775809ns"', + ); + }); +}); + +describe("formatDuration", () => { + it('formats zero as "0s"', () => { + expect(formatDuration(0)).toBe("0s"); + }); + + it("formats whole hours/minutes/seconds", () => { + expect(formatDuration(3_600_000_000_000)).toBe("1h0m0s"); + }); + + it("normalizes 90s to 1m30s", () => { + expect(formatDuration(90_000_000_000)).toBe("1m30s"); + }); + + it("formats a sub-second fraction", () => { + expect(formatDuration(1_500_000_000)).toBe("1.5s"); + }); + + it("round-trips through parseDuration", () => { + expect(formatDuration(parseDuration("1h"))).toBe("1h0m0s"); + expect(formatDuration(parseDuration("90s"))).toBe("1m30s"); + }); + + it("includes a sub-second fraction alongside minutes", () => { + expect(formatDuration(parseDuration("1m0.5s"))).toBe("1m0.5s"); + }); + + it("includes a sub-second fraction alongside hours", () => { + expect(formatDuration(parseDuration("1h0.5s"))).toBe("1h0m0.5s"); + }); + + it("includes a sub-second fraction alongside hours and minutes", () => { + expect(formatDuration(parseDuration("1h1m0.5s"))).toBe("1h1m0.5s"); + }); +}); diff --git a/apps/cli/src/command-internal/edge-runtime-image.ts b/apps/cli/src/command-internal/edge-runtime-image.ts index b47205bd89..9b1972bddb 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.ts @@ -1,4 +1,4 @@ -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; +import { legacyProjectStatePaths } from "../shared/config/legacy-project-state.paths.ts"; import { usesStackState } from "./stack-backend.ts"; import { Effect, type FileSystem, Option, type Path } from "effect"; import { DENO1_EDGE_RUNTIME_VERSION } from "../shared/functions/functions.shared.ts"; diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts index 572df0bea5..535e628a85 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts @@ -3,6 +3,7 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { vi } from "vitest"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { DebugFlag, NetworkIdFlag } from "./global-flags.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; @@ -10,7 +11,7 @@ import { CommandSettings } from "../config/command-settings.service.ts"; import { DockerRun, type DockerRunOpts } from "./docker-run.service.ts"; import { edgeRuntimeScriptLayer } from "./edge-runtime-script.layer.ts"; import { EdgeRuntimeScript } from "./edge-runtime-script.service.ts"; -import { projectStateModeLayer } from "./project-state-mode.ts"; +import { projectStateModeLayer } from "../shared/config/project-state-mode.ts"; import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { seedLegacyStateDecoys } from "../../tests/helpers/legacy-state-guard.ts"; import { seedLock } from "../../tests/helpers/stack-lock.ts"; @@ -43,7 +44,6 @@ function fakeDocker(result: { exitCode: number; stdout?: string; stderr?: string // falls back to the default tag. function makeCliSettings(workdir = "/nonexistent-workdir") { return Layer.succeed(CommandSettings, { - dbPassword: Option.none(), githubToken: Option.none(), workdirEnvValue: Option.none(), profile: "supabase", @@ -82,6 +82,7 @@ function setup( Layer.succeed(DebugFlag, false), Layer.succeed(NetworkIdFlag, Option.none()), BunServices.layer, + cliConfigValuesTestLayer, ), ), ); @@ -214,7 +215,7 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { ), ), ); - seedLegacyStateDecoys(workdir); + yield* seedLegacyStateDecoys(workdir); const { layer, docker } = setup( { exitCode: 1, stdout: "", stderr: "main worker has been destroyed\n" }, diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.ts index 94fb44e84d..9762108961 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.ts @@ -4,6 +4,7 @@ import * as Net from "node:net"; import { DebugFlag, NetworkIdFlag } from "./global-flags.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { readDbToml } from "./db-config.toml-read.ts"; import { getRegistryImageUrl } from "./docker-registry.ts"; import { DockerRun } from "./docker-run.service.ts"; @@ -50,6 +51,7 @@ export const edgeRuntimeScriptLayer = Layer.effect( const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const runtimeInfo = yield* RuntimeInfo; + const configValues = yield* CliConfigValues; // The pg-delta container needs `host.docker.internal:host-gateway` on Linux only, so a // `host.docker.internal` local DB host (from SUPABASE_SERVICES_HOSTNAME) resolves inside the // container on Linux/dev-container; Docker Desktop already provides this on macOS/Windows. @@ -80,6 +82,7 @@ export const edgeRuntimeScriptLayer = Layer.effect( const denoVersion = opts.denoVersion ?? (yield* readDbToml(fs, path, workdir).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.mapError((error) => new EdgeRuntimeScriptError({ message: error.message })), )).denoVersion; const registryImage = yield* getRegistryImageUrl( diff --git a/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts index 4a2ede6846..d7f3e67eff 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts @@ -16,7 +16,7 @@ describe("buildEdgeRuntimeStartCmd", () => { ]); }); - it("drops --port when allocation failed (Go preserves prior behaviour)", () => { + it("drops --port when allocation failed", () => { expect(buildEdgeRuntimeStartCmd({ port: Option.none(), debug: false })).toEqual([ "edge-runtime", "start", diff --git a/apps/cli/src/command-internal/experimental-gate.unit.test.ts b/apps/cli/src/command-internal/experimental-gate.unit.test.ts index 67d4b8324e..34ca11232c 100644 --- a/apps/cli/src/command-internal/experimental-gate.unit.test.ts +++ b/apps/cli/src/command-internal/experimental-gate.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { ExperimentalFlag } from "./global-flags.ts"; import { ExperimentalRequiredError, requireExperimental } from "./experimental-gate.ts"; @@ -8,92 +9,87 @@ import { ExperimentalRequiredError, requireExperimental } from "./experimental-g const ENV = "SUPABASE_EXPERIMENTAL"; const withFlag = (value: boolean, args: ReadonlyArray = []) => Layer.mergeAll(Layer.succeed(ExperimentalFlag, value), Layer.succeed(CliArgs, { args })); -const withShellEnv = (env: Readonly>) => - ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })); describe("requireExperimental", () => { it.effect("passes when --experimental is set", () => requireExperimental.pipe(Effect.provide(withFlag(true))), ); - it.effect("fails with Go's byte-exact message when neither flag nor env is set", () => + it.effect("fails with the gate message when neither flag nor env is set", () => Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide(Layer.merge(withFlag(false), withShellEnv({}))), - Effect.flip, + const error = yield* withEnvVar( + ENV, + undefined, + requireExperimental.pipe(Effect.provide(withFlag(false)), Effect.flip), ); expect(error).toBeInstanceOf(ExperimentalRequiredError); expect(error.message).toBe("must set the --experimental flag to run this command"); }), ); - it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag (viper AutomaticEnv)", () => + it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag", () => Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide(Layer.merge(withFlag(false), withShellEnv({ [ENV]: "1" }))), - Effect.exit, + const exit = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe(Effect.provide(withFlag(false)), Effect.exit), ); expect(Exit.isSuccess(exit)).toBe(true); }), ); - it.effect( - "fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit (viper Changed wins)", - () => - Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide( - Layer.merge(withFlag(false, ["--experimental=false"]), withShellEnv({ [ENV]: "1" })), - ), + it.effect("fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit", () => + Effect.gen(function* () { + const error = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe( + Effect.provide(withFlag(false, ["--experimental=false"])), Effect.flip, - ); - expect(error).toBeInstanceOf(ExperimentalRequiredError); - }), + ), + ); + expect(error).toBeInstanceOf(ExperimentalRequiredError); + }), ); it.effect( "passes with SUPABASE_EXPERIMENTAL=1 when --experimental=false is a positional operand after --", () => Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide( - Layer.merge( - withFlag(false, ["--", "--experimental=false"]), - withShellEnv({ [ENV]: "1" }), - ), + const exit = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe( + Effect.provide(withFlag(false, ["--", "--experimental=false"])), + Effect.exit, ), - Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); }), ); - it.effect( - "a repeated --experimental=false --experimental=true keeps the LAST occurrence (viper Set() wins)", - () => - Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide( - withFlag(false, ["db", "pull", "--experimental=false", "--experimental=true"]), - ), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - }), + it.effect("a repeated --experimental=false --experimental=true keeps the LAST occurrence", () => + Effect.gen(function* () { + const exit = yield* requireExperimental.pipe( + Effect.provide( + withFlag(false, ["db", "pull", "--experimental=false", "--experimental=true"]), + ), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + }), ); - it.effect( - "a repeated --experimental=true --experimental=false keeps the LAST occurrence (viper Set() wins)", - () => - Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide( - withFlag(true, ["db", "pull", "--experimental=true", "--experimental=false"]), - ), - Effect.flip, - ); - expect(error).toBeInstanceOf(ExperimentalRequiredError); - }), + it.effect("a repeated --experimental=true --experimental=false keeps the LAST occurrence", () => + Effect.gen(function* () { + const error = yield* requireExperimental.pipe( + Effect.provide( + withFlag(true, ["db", "pull", "--experimental=true", "--experimental=false"]), + ), + Effect.flip, + ); + expect(error).toBeInstanceOf(ExperimentalRequiredError); + }), ); it.effect("a repeated --experimental=false --experimental (bare) keeps the LAST occurrence", () => diff --git a/apps/cli/src/command-internal/fail-on.unit.test.ts b/apps/cli/src/command-internal/fail-on.unit.test.ts index 5bf34de451..497cdf2b10 100644 --- a/apps/cli/src/command-internal/fail-on.unit.test.ts +++ b/apps/cli/src/command-internal/fail-on.unit.test.ts @@ -35,7 +35,7 @@ describe("makeLevelEnum (exact-ci matcher — db advisors)", () => { expect(advisors.toEnum("ERROR")).toBe(2); }); - it("does NOT match a mixed-case level (Go's switch is exact)", () => { + it("does NOT match a mixed-case level (matching is exact)", () => { expect(advisors.toEnum("Info")).toBe(-1); expect(advisors.toEnum("warning")).toBe(-1); }); diff --git a/apps/cli/src/command-internal/go-float.ts b/apps/cli/src/command-internal/format-float.ts similarity index 61% rename from apps/cli/src/command-internal/go-float.ts rename to apps/cli/src/command-internal/format-float.ts index 286cef0f98..d180250dff 100644 --- a/apps/cli/src/command-internal/go-float.ts +++ b/apps/cli/src/command-internal/format-float.ts @@ -1,14 +1,14 @@ /** - * Formats a number the way Go's `fmt.Sprintf("%v", float64)` does: shortest `%g`, exponent - * notation when the decimal exponent is `< -4` or `>= 6` (`1000000` → `1e+06`), fixed notation - * otherwise, with a signed, at-least-two-digit exponent. + * Formats a number in its shortest round-trip form, switching to exponent notation when the + * decimal exponent is `< -4` or `>= 6` (`1000000` → `1e+06`), with a signed, at-least-two-digit + * exponent. * * Shared by `db query`'s value formatter and `postgres-config`'s pretty table. */ -export function goFormatFloat(n: number): string { +export function formatGeneralFloat(n: number): string { if (Number.isNaN(n)) return "NaN"; if (!Number.isFinite(n)) return n > 0 ? "+Inf" : "-Inf"; - // Go's `%v` preserves the sign of negative zero; `n === 0` is true for both `+0` and `-0`. + // Negative zero keeps its sign; `n === 0` is true for both `+0` and `-0`. if (Object.is(n, -0)) return "-0"; if (n === 0) return "0"; const neg = n < 0; diff --git a/apps/cli/src/command-internal/format-float.unit.test.ts b/apps/cli/src/command-internal/format-float.unit.test.ts new file mode 100644 index 0000000000..d82379056d --- /dev/null +++ b/apps/cli/src/command-internal/format-float.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { formatGeneralFloat } from "./format-float.ts"; + +describe("formatGeneralFloat", () => { + it("renders fixed notation within the [-4, 6) decimal-exponent range", () => { + expect(formatGeneralFloat(100)).toBe("100"); + expect(formatGeneralFloat(100000)).toBe("100000"); + expect(formatGeneralFloat(0.5)).toBe("0.5"); + expect(formatGeneralFloat(0.0001)).toBe("0.0001"); + }); + + it("switches to signed exponent notation at exponent >= 6 or < -4", () => { + expect(formatGeneralFloat(1000000)).toBe("1e+06"); + expect(formatGeneralFloat(100000000000)).toBe("1e+11"); + expect(formatGeneralFloat(123456789)).toBe("1.23456789e+08"); + expect(formatGeneralFloat(0.00001)).toBe("1e-05"); + }); + + it("preserves the sign for negative exponent-notation values", () => { + expect(formatGeneralFloat(-1000000)).toBe("-1e+06"); + }); + + it("renders zero as a bare 0", () => { + expect(formatGeneralFloat(0)).toBe("0"); + }); +}); diff --git a/apps/cli/src/command-internal/functions-go-config.ts b/apps/cli/src/command-internal/functions-go-config.ts deleted file mode 100644 index d107110529..0000000000 --- a/apps/cli/src/command-internal/functions-go-config.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Effect } from "effect"; -import type { FunctionsGoConfigCompat } from "../shared/functions/functions-config.ts"; -import { loadLocalProjectContext } from "./local-project-context.ts"; -import { resolveLocalConfigValues } from "./local-config-values.ts"; - -function toError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)); -} - -/** - * Config resolution for the native `functions` Docker paths (`deploy`/`download`/`serve`), - * injected into `functions-config.ts`'s `loadFunctionsCliConfig` so `shared/functions/` never - * imports the command tree's validation directly. - * - * Delegates to the same `loadLocalProjectContext`/`resolveLocalConfigValues` pair - * `start`/`stop`/`status` already use, rather than re-implementing either. Their derived - * local-dev values (JWTs, URLs) are discarded here; only `projectId`/`edgeRuntimeDenoVersion` and - * the validation side effect (throws on the first failure) matter to these three commands. - */ -export const functionsGoConfigCompat: FunctionsGoConfigCompat = { - load: ({ projectRoot, projectRef }) => - Effect.gen(function* () { - const context = yield* loadLocalProjectContext(projectRoot, toError, projectRef); - const validated = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - context.config, - context.hostname, - projectRoot, - context.projectEnvValues, - context.loaded?.document, - // No `[remotes.]` override-tier gating (empty set, the default): the remote - // block already merged over the base config at file level via - // `loadLocalProjectContext`'s `projectRef` threading above. Known narrow divergence: - // an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` still beats a matched remote - // block's own `deno_version` here, since computing the override keys needs - // `db-config.toml-read.ts`'s remote-resolution pipeline, which this path doesn't run. - undefined, - projectRef, - ), - catch: toError, - }); - return { - loaded: context.loaded, - projectEnvValues: context.projectEnvValues, - // `context.projectId`, not `validated.projectId`: the context's id is the one built for - // Docker naming/labels — sanitized, `--project-ref` defaulted, and - // `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` block matched (see - // `local-project-context.ts`'s gate). `validated.projectId` exists only to feed - // `validateResolvedConfig`'s emptiness check and skips that gate — see its own doc - // comment. - projectId: context.projectId, - denoVersion: validated.edgeRuntimeDenoVersion, - }; - }), -}; diff --git a/apps/cli/src/command-internal/functions-local-config.ts b/apps/cli/src/command-internal/functions-local-config.ts new file mode 100644 index 0000000000..8ded3eed41 --- /dev/null +++ b/apps/cli/src/command-internal/functions-local-config.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import type { FunctionsLocalConfigLoader } from "../shared/functions/functions-config.ts"; +import { loadLocalProjectContext } from "./local-project-context.ts"; +import { resolveLocalConfigValues } from "./local-config-values.ts"; + +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +/** + * Config resolution for the native `functions` Docker paths (`deploy`/`download`/`serve`), + * injected into `functions-config.ts`'s `loadFunctionsCliConfig` so `shared/functions/` never + * imports the command tree's validation directly. + * + * Delegates to the same `loadLocalProjectContext`/`resolveLocalConfigValues` pair + * `start`/`stop`/`status` already use, rather than re-implementing either. Their derived + * local-dev values (JWTs, URLs) are discarded here; only `projectId`/`edgeRuntimeDenoVersion` and + * the validation side effect (fails on the first error) matter to these three commands. + */ +export const functionsLocalConfigLoader: FunctionsLocalConfigLoader = { + load: ({ projectRoot, projectRef }) => + Effect.gen(function* () { + const context = yield* loadLocalProjectContext(projectRoot, toError, projectRef); + const validated = yield* resolveLocalConfigValues( + context.config, + context.hostname, + projectRoot, + context.resolvedConfig.loaded.document, + ).pipe(Effect.mapError(toError)); + return { + loaded: { config: context.config, document: context.resolvedConfig.loaded.document }, + resolvedConfig: context.resolvedConfig, + configPath: context.resolvedConfig.hasConfigFile + ? context.resolvedConfig.loaded.path + : undefined, + projectEnvValues: context.projectEnvValues, + // `context.projectId` is the id built for Docker naming/labels; `validated.projectId` + // exists only to feed `validateResolvedConfig`'s emptiness check. + projectId: context.projectId, + denoVersion: validated.edgeRuntimeDenoVersion, + }; + }), +}; diff --git a/apps/cli/src/command-internal/glob.unit.test.ts b/apps/cli/src/command-internal/glob.unit.test.ts index 0e79804e1a..a424671c2e 100644 --- a/apps/cli/src/command-internal/glob.unit.test.ts +++ b/apps/cli/src/command-internal/glob.unit.test.ts @@ -157,7 +157,7 @@ describe("walkSqlFiles", () => { }).pipe(Effect.provide(layer)); }); - it.effect("recurses into subdirectories, already sorted in Go's byte order", () => { + it.effect("recurses into subdirectories, already sorted in byte order", () => { const layer = fakeWalkFs( { "/schemas": ["nested", "top.sql"], "/schemas/nested": ["inner.sql"] }, { @@ -194,22 +194,19 @@ describe("walkSqlFiles", () => { }, ); - it.effect( - "does not descend into a symlinked subdirectory (Go's fs.WalkDir/afero.Walk no-follow)", - () => { - const layer = fakeWalkFs( - { "/schemas": ["linked", "top.sql"], "/schemas/linked": ["secret.sql"] }, - { "/schemas/linked": "Directory", "/schemas/top.sql": "File" }, - undefined, - new Set(["/schemas/linked"]), - ); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const files = yield* walkSqlFiles(fs, "/schemas", ""); - expect([...files]).toEqual(["top.sql"]); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("does not descend into a symlinked subdirectory", () => { + const layer = fakeWalkFs( + { "/schemas": ["linked", "top.sql"], "/schemas/linked": ["secret.sql"] }, + { "/schemas/linked": "Directory", "/schemas/top.sql": "File" }, + undefined, + new Set(["/schemas/linked"]), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* walkSqlFiles(fs, "/schemas", ""); + expect([...files]).toEqual(["top.sql"]); + }).pipe(Effect.provide(layer)); + }); it.effect("excludes a symlinked .sql file instead of applying its target", () => { const layer = fakeWalkFs( diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index df9c33e193..12723088bf 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -2,21 +2,22 @@ import { Effect, Option } from "effect"; import { Flag, GlobalFlag } from "effect/cli"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS } from "./db-target-flags.ts"; -import { viperEnvBool, viperEnvBoolWithProjectFallback } from "./viper-env.ts"; +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../shared/cli/value-consuming-flags.ts"; +import { envValue } from "../shared/config/env-option.ts"; +import { + parseEnvBool, + supabaseEnvBool, + supabaseEnvBoolWithProjectFallback, +} from "./supabase-env.ts"; +import { OFF_WORDS } from "./target/remote-vocabulary.ts"; -// The CLI's global-flag registry is tree-wide, so `-o/--output` can't be redeclared per command to -// vary its allowed values; this models it as the union of every command's accepted values, and -// each handler honors only the subset it cares about (e.g. `db query` reads `table`/`csv`; other -// commands ignore them and fall through to text). -// -// These flag description strings are user-visible in shell completion (`__complete` candidate -// descriptions), so keep them accurate when changing a flag. -/** - * Every value the global `-o/--output` flag accepts. Per-command `outputFormats` overrides (e.g. - * `config diff`'s handler-level rejection) widen against this list, so a value added here flows to - * those commands and their tests automatically. - */ +// `-o/--output` is tree-wide and can't vary per command, so it takes the union of every command's +// values and each handler honors its subset. +// Flag descriptions are user-visible in shell completion (`__complete`), so keep them accurate. +/** Every value `-o/--output` accepts; per-command `outputFormats` overrides widen against it. */ export const GLOBAL_OUTPUT_FORMATS = [ "env", "pretty", @@ -35,12 +36,7 @@ export const OutputFlag = GlobalFlag.Setting("output")({ ), }); -/** - * The TS-only `--output-format` global, accepted on any subcommand. - * - * A value-taking global flag must also be registered in `PERSISTENT_VALUE_FLAG_NAMES` - * (`shared/cli/cobra-flag-groups.ts`), or the pre-parse argv scanners won't consume its value. - */ +/** The TS-only `--output-format` global, accepted on any subcommand. */ export const OutputFormatFlag = GlobalFlag.Setting("output-format")({ flag: Flag.Literals("output-format", ["text", "json", "stream-json"]).pipe( Flag.withDescription("Output format: text (default), json, or stream-json (NDJSON)"), @@ -87,7 +83,9 @@ export const NetworkIdFlag = GlobalFlag.Setting("network-id")({ export const YesFlag = GlobalFlag.Setting("yes")({ flag: Flag.Boolean("yes").pipe( - Flag.withDescription("answer yes to all prompts"), + Flag.withDescription( + "answer yes to all prompts; hosted changes only accept this flag or SUPABASE_YES exported in your shell, never supabase/.env or another .env file", + ), Flag.withDefault(false), ), }); @@ -99,10 +97,13 @@ export const DnsResolverFlag = GlobalFlag.Setting("dns-resolver")({ ), }); +// Kept parsed (and hidden) only so passing it fails with the removal error from +// `withCommandTelemetry` instead of an unknown-flag parse error. export const CreateTicketFlag = GlobalFlag.Setting("create-ticket")({ flag: Flag.Boolean("create-ticket").pipe( - Flag.withDescription("create a support ticket for any CLI error"), + Flag.withDescription("Removed: report CLI problems with `supabase issue bug` instead."), Flag.withDefault(false), + Flag.withHidden, ), }); @@ -114,12 +115,8 @@ export const AgentFlag = GlobalFlag.Setting("agent")({ }); /** - * Every global/persistent flag declared above. - * - * A value-taking flag added here must also be added to `PERSISTENT_VALUE_FLAG_NAMES` - * (`shared/cli/cobra-flag-groups.ts`), which the handler-side pflag scans and pre-parse token - * scanners both derive their token set from. A flag missed there fails silently: an unregistered - * value flag won't consume its following token, so that token gets misread as positional. + * Every global flag declared above. A value-taking one must also join `PERSISTENT_VALUE_FLAG_NAMES` + * (`shared/cli/flag-groups.ts`), or its value token is silently misread as positional. */ export const GLOBAL_FLAGS = [ OutputFlag, @@ -138,14 +135,8 @@ export const GLOBAL_FLAGS = [ export const ROOT_GLOBAL_FLAGS = [OutputFormatFlag, ...GLOBAL_FLAGS] as const; /** - * Resolves the current value of every global/persistent flag above, keyed by its own CLI flag - * name. Used by `telemetry/command-telemetry.ts` as a fallback for a global flag not present in a - * handler's own `flags` record. - * - * Reads via `Effect.serviceOption` so a caller without the global-flag context (e.g. a focused - * unit test) gets an empty record instead of a missing-service defect. Reads each flag - * individually, since each `Setting`'s distinct value type can't be looped over without an - * `as` cast; `global-flags.unit.test.ts` keeps the resolved id set in sync with `GLOBAL_FLAGS`. + * Every global flag's current value by name, as telemetry's fallback for a flag missing from a + * handler's `flags`; empty when `serviceOption` finds no global-flag context. */ export const globalFlagValues = Effect.gen(function* () { const values: Record = {}; @@ -165,27 +156,16 @@ export const globalFlagValues = Effect.gen(function* () { return values; }); -const PFLAG_FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); +const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); -/** - * Raw argv truncated at the first bare `--` operand terminator. This CLI's own lexer stops parsing - * flags there too, so everything after is a positional operand, e.g. a migration name literally - * called `--experimental=false` passed as `db pull -- --experimental=false`. The argv-scanning - * `*ExplicitlyFalse` heuristics below must only look at this region, or a positional operand that - * merely looks like a flag gets mistaken for an explicit one. - */ +/** Argv before the first bare `--`; the rest is an operand (`db pull -- --experimental=false`). */ const argsBeforeOperandTerminator = (args: ReadonlyArray): ReadonlyArray => { const terminatorIndex = args.indexOf("--"); return terminatorIndex === -1 ? args : args.slice(0, terminatorIndex); }; -/** - * Drops tokens that pflag would consume as a value-consuming flag's value in space-separated form - * (`--flag value` / `-f value`), so the `--yes`/`--experimental`/`--debug` argv scanners below - * don't mistake a consumed value token for an explicit occurrence of the global flag — e.g. - * `db pull --password --experimental=false` treats `--experimental=false` as `--password`'s - * value, not a changed `--experimental`. - */ +// Drops a flag's space-separated value, so `db pull --password --experimental=false` doesn't read +// as a changed `--experimental`. const nonValueConsumedTokens = (args: ReadonlyArray): ReadonlyArray => { const kept: Array = []; let skipNext = false; @@ -207,71 +187,78 @@ const nonValueConsumedTokens = (args: ReadonlyArray): ReadonlyArray`. A plain boolean flag can't - * distinguish an explicit `--yes=false` from the omitted default, so this scans the raw argv up to - * the first `--` operand terminator (see {@link argsBeforeOperandTerminator}), skipping tokens - * consumed as another flag's value (see {@link nonValueConsumedTokens}). + * The last `--yes`/`--yes=` in argv, or `undefined` if there is none. The parser keeps the + * first of a repeat and can't tell `--yes=false` from the default, so callers reread argv; a bare + * `--yes` reads a boolean literal that follows it, as the parser does. */ -const yesFlagExplicitlyFalse = (args: ReadonlyArray): boolean => - nonValueConsumedTokens(argsBeforeOperandTerminator(args)).some( - (arg) => arg.startsWith("--yes=") && PFLAG_FALSE_VALUES.has(arg.slice("--yes=".length)), - ); +const yesFlagFromArgs = (args: ReadonlyArray): boolean | undefined => { + const tokens = nonValueConsumedTokens(argsBeforeOperandTerminator(args)); + let result: boolean | undefined; + for (const [index, arg] of tokens.entries()) { + if (arg === "--yes") { + result = !OFF_WORDS.has(tokens[index + 1] ?? ""); + } else if (arg.startsWith("--yes=")) { + const value = arg.slice("--yes=".length); + result = !(FALSE_VALUES.has(value) || OFF_WORDS.has(value)); + } + } + return result; +}; -/** - * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An - * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading - * {@link YesFlag} directly anywhere a command auto-confirms a prompt. - */ -export const resolveYes = Effect.gen(function* () { +export type YesSource = { readonly via: "flag" } | { readonly via: "env" }; + +/** The origin of a granted `--yes`, or `None`; an explicit `--yes=false` wins over the env. */ +export const resolveYesSource = Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; - if (yesFlagExplicitlyFalse(cliArgs.args)) { - return false; + const explicit = yesFlagFromArgs(cliArgs.args); + if (explicit === false) { + return Option.none(); } - return flag || (yield* viperEnvBool("SUPABASE_YES")); + if (explicit ?? flag) return Option.some({ via: "flag" }); + const raw = yield* envValue("SUPABASE_YES"); + return raw !== undefined && parseEnvBool(raw) + ? Option.some({ via: "env" }) + : Option.none(); }); /** - * `--yes` resolved with the project `.env` consulted too, for commands that load the nested - * project env before prompting (`migration down`, `migration repair --all`). Shell env - * *presence* (any value) suppresses the file value entirely (see - * {@link viperEnvBoolWithProjectFallback}); an explicit `--yes` wins over both. `projectEnv` is - * the loaded map from `loadProjectEnv`. + * `--yes` with a `SUPABASE_YES` fallback; an explicit flag (including `--yes=false`) wins over the + * env. Prefer it to reading {@link YesFlag} directly. + */ +export const resolveYes = Effect.map(resolveYesSource, Option.isSome); + +/** + * {@link resolveYes} that also consults the project `.env` (the `loadProjectEnvValues` map) for + * commands that load it before prompting; shell env presence suppresses the file value (see + * {@link supabaseEnvBoolWithProjectFallback}), and an explicit `--yes` wins over both. */ export const resolveYesWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; - if (yesFlagExplicitlyFalse(cliArgs.args)) { - return false; + const explicit = yesFlagFromArgs(cliArgs.args); + if (explicit !== undefined) { + return explicit; } - return flag || (yield* viperEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); + return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); }); -/** - * Resolves the raw argv's *last* explicit `--experimental` occurrence to a boolean, or `undefined` - * if it never appears before the first `--` operand terminator. Repeated occurrences resolve to - * the last one, the opposite of how this CLI's own flag parser resolves a repeat (first wins), so - * a caller must reread raw argv rather than trust the parsed flag. Tokens consumed as another - * flag's value are skipped (see {@link nonValueConsumedTokens}). - */ +// The last `--experimental`/`--experimental=` in argv, or `undefined`; the parser keeps the +// first of a repeat, so callers reread argv. const experimentalFlagFromArgs = (args: ReadonlyArray): boolean | undefined => { let result: boolean | undefined; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { if (arg === "--experimental") { result = true; } else if (arg.startsWith("--experimental=")) { - result = !PFLAG_FALSE_VALUES.has(arg.slice("--experimental=".length)); + result = !FALSE_VALUES.has(arg.slice("--experimental=".length)); } } return result; }; -/** - * `--experimental` resolved with an env fallback: `SUPABASE_EXPERIMENTAL` enables experimental - * commands when the flag isn't passed. An explicit `--experimental` (including - * `--experimental=false`, and the last of a repeated flag) wins over the env. - */ +/** `--experimental` with a `SUPABASE_EXPERIMENTAL` fallback; an explicit flag, even false, wins. */ export const resolveExperimental = Effect.gen(function* () { const flag = yield* ExperimentalFlag; const cliArgs = yield* CliArgs; @@ -279,13 +266,10 @@ export const resolveExperimental = Effect.gen(function* () { if (explicit !== undefined) { return explicit; } - return flag || (yield* viperEnvBool("SUPABASE_EXPERIMENTAL")); + return flag || (yield* supabaseEnvBool("SUPABASE_EXPERIMENTAL")); }); -/** - * `--experimental` with project `.env` fallback. Shell-env presence wins over the file; - * an explicit flag wins over both. See {@link viperEnvBoolWithProjectFallback}. - */ +/** {@link resolveExperimental} plus project `.env`: shell env beats the file, a flag beats both. */ export const resolveExperimentalWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { const flag = yield* ExperimentalFlag; @@ -294,34 +278,26 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record` occurrence in argv resolves to false. Tracks the - * last occurrence (unlike {@link yesFlagExplicitlyFalse}'s "any occurrence" check), since a - * trailing bare `--debug` after an earlier `--debug=false` must win. Scans only the flag-parsing - * region (see {@link argsBeforeOperandTerminator}) and skips tokens consumed as another flag's - * value (see {@link nonValueConsumedTokens}). - */ +// True when the last `--debug`/`--debug=` in argv resolves to false: a trailing bare +// `--debug` must beat an earlier `--debug=false`. const debugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { let lastExplicitlyFalse = false; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { if (arg === "--debug") { lastExplicitlyFalse = false; } else if (arg.startsWith("--debug=")) { - lastExplicitlyFalse = PFLAG_FALSE_VALUES.has(arg.slice("--debug=".length)); + lastExplicitlyFalse = FALSE_VALUES.has(arg.slice("--debug=".length)); } } return lastExplicitlyFalse; }; /** - * `--debug` resolved with an env fallback, and the project `.env` consulted too, for debug-gated - * behavior downstream of a command that already loaded the nested project env. `loadProjectEnv` - * is pure, so callers pass the loaded map through explicitly — same shape as - * {@link resolveYesWithProjectEnv}. Shell env *presence* suppresses the file value; an explicit - * `--debug` wins over both. + * `--debug` with env and project `.env` fallbacks, for debug-gated code downstream of a command + * that already loaded the project env. */ export const resolveDebugWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { @@ -330,5 +306,5 @@ export const resolveDebugWithProjectEnv = (projectEnv: Record) = if (debugFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || (yield* viperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv)); + return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv)); }); diff --git a/apps/cli/src/command-internal/global-flags.unit.test.ts b/apps/cli/src/command-internal/global-flags.unit.test.ts index f6153d82a2..390f3fa4cf 100644 --- a/apps/cli/src/command-internal/global-flags.unit.test.ts +++ b/apps/cli/src/command-internal/global-flags.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import { withConfigEnv, withEmptyConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { GLOBAL_FLAGS, @@ -15,6 +16,8 @@ import { YesFlag, globalFlagValues, resolveDebugWithProjectEnv, + resolveYesSource, + resolveYesWithProjectEnv, } from "./global-flags.ts"; describe("globalFlagValues", () => { @@ -120,3 +123,89 @@ describe("resolveDebugWithProjectEnv", () => { }, ); }); + +// `parsed` is what the flag parser yields, which keeps the first occurrence of a repeated flag. +describe("resolveYesSource", () => { + const resolve = ( + args: ReadonlyArray, + { parsed, shellYes }: { readonly parsed: boolean; readonly shellYes?: string }, + ) => + withEmptyConfigEnv( + withConfigEnv(shellYes === undefined ? {} : { SUPABASE_YES: shellYes }, resolveYesSource), + ).pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, parsed), Layer.succeed(CliArgs, { args })), + ), + ); + + it.effect.each([ + { args: ["--yes=false", "--yes"], parsed: false }, + { args: ["--yes=false", "--yes=true"], parsed: false }, + { args: ["--yes", "false", "--yes"], parsed: false }, + { args: ["--yes", "--", "--yes=false"], parsed: true }, + { args: ["--yes", "--password", "--yes=false"], parsed: true }, + ])("confirms $args because the last --yes is true", ({ args, parsed }) => + Effect.gen(function* () { + expect(yield* resolve(args, { parsed })).toEqual(Option.some({ via: "flag" })); + }), + ); + + it.effect.each([ + { args: ["--yes", "--yes=false"], parsed: true }, + { args: ["--yes=true", "--yes=false"], parsed: true }, + { args: ["--yes=false"], parsed: false }, + { args: ["--yes", "false"], parsed: false }, + { args: ["--yes=no"], parsed: false }, + { args: ["--yes", "off"], parsed: false }, + ])( + "denies $args because the last --yes is false, even with SUPABASE_YES set", + ({ args, parsed }) => + Effect.gen(function* () { + expect(yield* resolve(args, { parsed, shellYes: "1" })).toEqual(Option.none()); + }), + ); + + it.effect("falls back to a shell SUPABASE_YES when --yes never appears", () => + Effect.gen(function* () { + expect(yield* resolve([], { parsed: false, shellYes: "1" })).toEqual( + Option.some({ via: "env" }), + ); + expect(yield* resolve([], { parsed: false })).toEqual(Option.none()); + }), + ); +}); + +describe("resolveYesWithProjectEnv", () => { + const resolve = ( + args: ReadonlyArray, + { + parsed, + projectEnv, + }: { readonly parsed: boolean; readonly projectEnv: Record }, + ) => + withEmptyConfigEnv(resolveYesWithProjectEnv(projectEnv)).pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, parsed), Layer.succeed(CliArgs, { args })), + ), + ); + + it.effect("lets the last --yes decide, in either order", () => + Effect.gen(function* () { + expect(yield* resolve(["--yes=false", "--yes"], { parsed: false, projectEnv: {} })).toBe( + true, + ); + expect( + yield* resolve(["--yes", "--yes=false"], { + parsed: true, + projectEnv: { SUPABASE_YES: "1" }, + }), + ).toBe(false); + }), + ); + + it.effect("falls back to the project env when --yes never appears", () => + Effect.gen(function* () { + expect(yield* resolve([], { parsed: false, projectEnv: { SUPABASE_YES: "1" } })).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/command-internal/go-child-exit.error.ts b/apps/cli/src/command-internal/go-child-exit.error.ts deleted file mode 100644 index 511f1a8d9f..0000000000 --- a/apps/cli/src/command-internal/go-child-exit.error.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Data, Runtime } from "effect"; - -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; - -/** - * A spawned `supabase-go` child process exited non-zero, or couldn't be spawned at all. - * - * Carries the exit code through `Runtime.errorExitCode` so it survives every `Effect.ensuring` - * finalizer up to `runCli`'s own exit call, instead of exiting the process directly and skipping - * them. `runCli` also special-cases this class to skip its own generic stderr line, since the - * child already wrote its own failure detail there. `exitCode` must be a real non-zero status; - * every construction site guards this before creating the error. - */ -export class GoChildExitError extends Data.TaggedError("GoChildExitError")<{ - readonly exitCode: number; - readonly message: string; -}> { - override readonly [Runtime.errorExitCode] = this.exitCode; - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.unknown; - } -} diff --git a/apps/cli/src/command-internal/go-duration.unit.test.ts b/apps/cli/src/command-internal/go-duration.unit.test.ts deleted file mode 100644 index b7b58558a0..0000000000 --- a/apps/cli/src/command-internal/go-duration.unit.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { formatGoDuration, parseGoDuration } from "./go-duration.ts"; - -describe("parseGoDuration", () => { - it("parses a single unit", () => { - expect(parseGoDuration("5s")).toBe(5_000_000_000); - expect(parseGoDuration("300ms")).toBe(300_000_000); - expect(parseGoDuration("1h")).toBe(3_600_000_000_000); - }); - - it("parses multiple units concatenated", () => { - expect(parseGoDuration("1h30m")).toBe(5_400_000_000_000); - }); - - it("parses a fractional value", () => { - expect(parseGoDuration("1.5s")).toBe(1_500_000_000); - }); - - it("parses a signed value", () => { - expect(parseGoDuration("-5s")).toBe(-5_000_000_000); - expect(parseGoDuration("+5s")).toBe(5_000_000_000); - }); - - it('treats a bare "0" as zero regardless of sign', () => { - expect(parseGoDuration("0")).toBe(0); - }); - - it("rejects an empty string", () => { - expect(() => parseGoDuration("")).toThrow('time: invalid duration ""'); - }); - - it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( - 'rejects a bare unit with no preceding digit ("%s")', - (input) => { - expect(() => parseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); - }, - ); - - it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { - expect(parseGoDuration(`1${unit}`)).toBe(1_000); - }); - - it('rejects a bare unit following a valid unit ("1hs")', () => { - expect(() => parseGoDuration("1hs")).toThrow('time: invalid duration "1hs"'); - }); - - it('rejects a negative bare unit ("-s")', () => { - expect(() => parseGoDuration("-s")).toThrow('time: invalid duration "-s"'); - }); - - it.each([".s", ".", "-.", "+.", ".h"])('rejects a lone "." with no digits ("%s")', (input) => { - expect(() => parseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); - }); - - it('accepts a fraction-only value with no leading digit (".5s")', () => { - expect(parseGoDuration(".5s")).toBe(500_000_000); - }); - - it("rejects a number with no unit", () => { - expect(() => parseGoDuration("5")).toThrow('time: missing unit in duration "5"'); - }); - - it("rejects an unknown unit", () => { - expect(() => parseGoDuration("5x")).toThrow('time: unknown unit in duration "5x"'); - }); - - it('truncates a sub-nanosecond fraction instead of rounding ("0.5ns")', () => { - expect(parseGoDuration("0.5ns")).toBe(0); - }); - - it('truncates a sub-nanosecond fraction instead of rounding ("1.9ns")', () => { - expect(parseGoDuration("1.9ns")).toBe(1); - }); - - it('rounds a long fractional remainder up like Go\'s float64 conversion ("0.999999999999999999s")', () => { - expect(parseGoDuration("0.999999999999999999s")).toBe(1_000_000_000); - }); - - it('rejects a duration that overflows math.MaxInt64 nanoseconds ("2562048h")', () => { - expect(() => parseGoDuration("2562048h")).toThrow('time: invalid duration "2562048h"'); - }); - - it("accepts Go's actual maximum parseable duration without overflowing", () => { - expect(() => parseGoDuration("2562047h47m16.854775807s")).not.toThrow(); - }); - - it('rejects a duration exactly 1ns past Go\'s true math.MaxInt64 ceiling ("9223372036854775808ns")', () => { - expect(() => parseGoDuration("9223372036854775808ns")).toThrow( - 'time: invalid duration "9223372036854775808ns"', - ); - }); - - it("accepts a duration exactly at Go's true math.MaxInt64 ceiling in nanoseconds", () => { - expect(() => parseGoDuration("9223372036854775807ns")).not.toThrow(); - }); - - it("accepts Go's exact minimum representable negative duration (math.MinInt64 ns)", () => { - expect(parseGoDuration("-9223372036854775808ns")).toBe(-9223372036854775808); - }); - - it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { - expect(parseGoDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); - }); - - it("rejects a duration 1ns past math.MinInt64", () => { - expect(() => parseGoDuration("-9223372036854775809ns")).toThrow( - 'time: invalid duration "-9223372036854775809ns"', - ); - }); -}); - -describe("formatGoDuration", () => { - it('formats zero as "0s"', () => { - expect(formatGoDuration(0)).toBe("0s"); - }); - - it("formats whole hours/minutes/seconds", () => { - expect(formatGoDuration(3_600_000_000_000)).toBe("1h0m0s"); - }); - - it("normalizes 90s to 1m30s", () => { - expect(formatGoDuration(90_000_000_000)).toBe("1m30s"); - }); - - it("formats a sub-second fraction", () => { - expect(formatGoDuration(1_500_000_000)).toBe("1.5s"); - }); - - it("round-trips through parseGoDuration", () => { - expect(formatGoDuration(parseGoDuration("1h"))).toBe("1h0m0s"); - expect(formatGoDuration(parseGoDuration("90s"))).toBe("1m30s"); - }); - - it("includes a sub-second fraction alongside minutes", () => { - expect(formatGoDuration(parseGoDuration("1m0.5s"))).toBe("1m0.5s"); - }); - - it("includes a sub-second fraction alongside hours", () => { - expect(formatGoDuration(parseGoDuration("1h0.5s"))).toBe("1h0m0.5s"); - }); - - it("includes a sub-second fraction alongside hours and minutes", () => { - expect(formatGoDuration(parseGoDuration("1h1m0.5s"))).toBe("1h1m0.5s"); - }); -}); diff --git a/apps/cli/src/command-internal/go-float.unit.test.ts b/apps/cli/src/command-internal/go-float.unit.test.ts deleted file mode 100644 index 7ebaa15172..0000000000 --- a/apps/cli/src/command-internal/go-float.unit.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { goFormatFloat } from "./go-float.ts"; - -describe("goFormatFloat", () => { - it("renders fixed notation within Go's [-4, 6) decimal-exponent range", () => { - expect(goFormatFloat(100)).toBe("100"); - expect(goFormatFloat(100000)).toBe("100000"); - expect(goFormatFloat(0.5)).toBe("0.5"); - expect(goFormatFloat(0.0001)).toBe("0.0001"); - }); - - it("switches to signed exponent notation at exponent >= 6 or < -4", () => { - expect(goFormatFloat(1000000)).toBe("1e+06"); - expect(goFormatFloat(100000000000)).toBe("1e+11"); - expect(goFormatFloat(123456789)).toBe("1.23456789e+08"); - expect(goFormatFloat(0.00001)).toBe("1e-05"); - }); - - it("preserves the sign for negative exponent-notation values", () => { - expect(goFormatFloat(-1000000)).toBe("-1e+06"); - }); - - it("renders zero as a bare 0", () => { - expect(goFormatFloat(0)).toBe("0"); - }); -}); diff --git a/apps/cli/src/command-internal/go-json.unit.test.ts b/apps/cli/src/command-internal/go-json.unit.test.ts deleted file mode 100644 index d00c4618f3..0000000000 --- a/apps/cli/src/command-internal/go-json.unit.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - encodeGoJsonCompact, - encodeGoJsonIndented, - escapeGoJsonString, - goJsonKindName, -} from "./go-json.ts"; - -describe("escapeGoJsonString", () => { - it("escapes quotes and backslashes like Go", () => { - expect(escapeGoJsonString(`a"b\\c`)).toBe('"a\\"b\\\\c"'); - }); - - it("HTML-escapes <, > and & (Go's default escapeHTML)", () => { - expect(escapeGoJsonString("
& ")).toBe('"\\u003ca\\u003e \\u0026 \\u003cb\\u003e"'); - }); - - it("uses short escapes for tab/newline/carriage-return", () => { - expect(escapeGoJsonString("a\tb\nc\rd")).toBe('"a\\tb\\nc\\rd"'); - }); - - it("uses \\u00xx for other control characters (no \\b / \\f shorthand)", () => { - expect(escapeGoJsonString("\b\f")).toBe('"\\u0008\\u000c"'); - }); - - it("escapes U+2028 and U+2029", () => { - expect(escapeGoJsonString("

")).toBe('"\\u2028\\u2029"'); - }); -}); - -describe("encodeGoJsonIndented", () => { - it("preserves object key insertion order (not alphabetical)", () => { - expect(encodeGoJsonIndented({ level: "error", message: "boom" })).toBe( - `{\n "level": "error",\n "message": "boom"\n}\n`, - ); - }); - - it("renders nested arrays of objects with 2-space indent and a trailing newline", () => { - const value = [{ function: "public.f1", issues: [{ level: "error", message: "test 1b" }] }]; - expect(encodeGoJsonIndented(value)).toBe( - [ - "[", - " {", - ' "function": "public.f1",', - ' "issues": [', - " {", - ' "level": "error",', - ' "message": "test 1b"', - " }", - " ]", - " }", - "]", - "", - ].join("\n"), - ); - }); - - it("renders empty arrays and objects compactly", () => { - expect(encodeGoJsonIndented([])).toBe("[]\n"); - expect(encodeGoJsonIndented({})).toBe("{}\n"); - expect(encodeGoJsonIndented({ issues: [] })).toBe(`{\n "issues": []\n}\n`); - }); -}); - -describe("encodeGoJsonCompact", () => { - it("matches Go's json.Marshal compact shape with HTML escaping", () => { - expect(encodeGoJsonCompact({ metadata_xml: "&stuff", type: "saml" })).toBe( - '{"metadata_xml":"\\u003cxml\\u003e\\u0026stuff\\u003c/xml\\u003e","type":"saml"}', - ); - }); - - it("keeps insertion order, compact separators, and no trailing newline", () => { - expect(encodeGoJsonCompact({ b: [1, 2], a: { c: true } })).toBe('{"b":[1,2],"a":{"c":true}}'); - expect(encodeGoJsonCompact([])).toBe("[]"); - expect(encodeGoJsonCompact(null)).toBe("null"); - }); - - it("preserves negative zero's sign, unlike plain JSON.stringify", () => { - expect(encodeGoJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); - expect(encodeGoJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); - expect(encodeGoJsonCompact({ extra: 0 })).toBe('{"extra":0}'); - }); - - it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { - const map = new Map([ - ["10", "a"], - ["2", "b"], - ]); - expect(encodeGoJsonCompact(map)).toBe('{"10":"a","2":"b"}'); - }); -}); - -describe("goJsonKindName", () => { - it("names every JSON-representable kind, including the generic fallback", () => { - expect(goJsonKindName([])).toBe("array"); - expect(goJsonKindName(1)).toBe("number"); - expect(goJsonKindName("s")).toBe("string"); - expect(goJsonKindName(true)).toBe("bool"); - expect(goJsonKindName(undefined)).toBe("value"); - }); -}); diff --git a/apps/cli/src/command-internal/go-proxy-invocation.ts b/apps/cli/src/command-internal/go-proxy-invocation.ts deleted file mode 100644 index eb0abfe66f..0000000000 --- a/apps/cli/src/command-internal/go-proxy-invocation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Context, Effect, Layer, Ref } from "effect"; - -interface GoProxyInvocationShape { - readonly markDelegated: Effect.Effect; - readonly wasDelegated: Effect.Effect; -} - -export class GoProxyInvocation extends Context.Service()( - "supabase/cli/GoProxyInvocation", -) {} - -export const goProxyInvocationLayer = Layer.effect( - GoProxyInvocation, - Effect.gen(function* () { - const delegated = yield* Ref.make(false); - return GoProxyInvocation.of({ - markDelegated: Ref.set(delegated, true), - wasDelegated: Ref.get(delegated), - }); - }), -); diff --git a/apps/cli/src/command-internal/go-proxy-target-env.integration.test.ts b/apps/cli/src/command-internal/go-proxy-target-env.integration.test.ts deleted file mode 100644 index 91125ffe75..0000000000 --- a/apps/cli/src/command-internal/go-proxy-target-env.integration.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, FileSystem, Layer, Option, Path } from "effect"; - -import { useTempWorkdir, VALID_TOKEN } from "../../tests/helpers/command-mocks.ts"; -import { isolatedHomeLayer } from "../../tests/helpers/command-mocks.ts"; -import { - mockAnalytics, - mockOutput, - mockTelemetryRuntime, - mockTty, -} from "../../tests/helpers/mocks.ts"; -import { - projectResponse, - provideEnv, - stubManagementApi, -} from "../../tests/helpers/permission-real-layers.ts"; -import { dbRemoteChanges } from "../commands/db/remote/changes/changes.handler.ts"; -import { genKeys } from "../commands/gen/keys/keys.handler.ts"; -import { PROJECT_NOT_LINKED_MESSAGE } from "../config/remote-target.service.ts"; -import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { - DebugFlag, - DnsResolverFlag, - OutputFlag, - ProfileFlag, - WorkdirFlag, -} from "./global-flags.ts"; -import { GoProxy } from "./go-proxy.service.ts"; -import { projectStateModeLayer, type ProjectStateMode } from "./project-state-mode.ts"; - -const LINKED_REF = "abcdefghijklmnopqrst"; - -const setup = ( - workdir: string, - mode: ProjectStateMode, - profile = "supabase", - interactive?: { readonly chosenRef: string }, -) => { - const calls: Array<{ readonly args: ReadonlyArray }> = []; - const poolerUrls: Array = []; - const layer = Layer.mergeAll( - BunServices.layer, - isolatedHomeLayer(workdir, { - SUPABASE_NO_KEYRING: "1", - ...(interactive === undefined ? {} : { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - }), - projectStateModeLayer(mode), - mockTty({ stdinIsTty: interactive !== undefined }), - mockOutput({ - promptSelectResponses: interactive === undefined ? [] : [interactive.chosenRef], - }).layer, - mockAnalytics().layer, - mockTelemetryRuntime(), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(DebugFlag, false), - Layer.succeed(ProfileFlag, profile), - Layer.succeed(WorkdirFlag, Option.some(workdir)), - Layer.succeed(OutputFlag, Option.none()), - Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(GoProxy, { - exec: (args, execOpts) => - Effect.sync(() => { - calls.push({ args }); - poolerUrls.push(execOpts?.env?.SUPABASE_INTERNAL_POOLER_URL); - }), - execCapture: () => Effect.die("the delegated commands do not capture output"), - }), - ); - return { calls, poolerUrls, layer }; -}; - -const POOLER_URL = - "postgresql://postgres.abcdefghijklmnopqrst:[YOUR-PASSWORD]@pooler.example.com:6543/postgres"; - -const writeStackLink = Effect.fnUntraced(function* (workdir: string, poolerUrl?: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); - yield* fs.writeFileString( - path.join(workdir, "supabase", ".link.json"), - JSON.stringify({ - project: { ref: LINKED_REF }, - active_branch: { - ref: LINKED_REF, - ...(poolerUrl === undefined ? {} : { pooler_url: poolerUrl }), - }, - fetchedAt: "2026-01-01T00:00:00.000Z", - }), - ); -}); - -const changesFlags = (overrides: { dbUrl?: Option.Option } = {}) => ({ - schema: [], - dbUrl: overrides.dbUrl ?? Option.none(), - linked: false, - password: Option.none(), -}); - -describe("Go-delegated linked target", () => { - const tmp = useTempWorkdir("supabase-go-target-env-"); - const env = { SUPABASE_NO_KEYRING: "1", SUPABASE_HOME: undefined }; - - it.effect("passes the linked ref to `db remote changes` as --project-ref in stack mode", () => { - const { calls, layer } = setup(tmp.current, "stack"); - return Effect.gen(function* () { - yield* writeStackLink(tmp.current); - yield* dbRemoteChanges(changesFlags()); - expect(calls).toEqual([{ args: ["db", "remote", "changes", "--project-ref", LINKED_REF] }]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("passes no ref when `db remote changes` has its own --db-url", () => { - const { calls, layer } = setup(tmp.current, "stack"); - return Effect.gen(function* () { - yield* writeStackLink(tmp.current); - yield* dbRemoteChanges(changesFlags({ dbUrl: Option.some("postgres://x@localhost/db") })); - expect(calls).toEqual([ - { - args: ["db", "remote", "changes", "--db-url", "postgres://x@localhost/db"], - }, - ]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("adds no ref and builds no target runtime in legacy mode", () => { - // An unknown profile fails the settings layer, so a legacy run that succeeds never built it. - const { calls, layer } = setup(tmp.current, "legacy", "no-such-profile"); - return Effect.gen(function* () { - yield* dbRemoteChanges(changesFlags()); - yield* genKeys({ projectRef: Option.none(), overrideName: [] }); - expect(calls).toEqual([{ args: ["db", "remote", "changes"] }, { args: ["gen", "keys"] }]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("passes the linked ref to `gen keys` as --project-ref in stack mode", () => { - const { calls, layer } = setup(tmp.current, "stack"); - return Effect.gen(function* () { - yield* writeStackLink(tmp.current); - yield* genKeys({ projectRef: Option.none(), overrideName: [] }); - expect(calls).toEqual([{ args: ["gen", "keys", "--project-ref", LINKED_REF] }]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("prompts for a project in `gen keys` when stack mode has nothing linked", () => { - const { calls, layer } = setup(tmp.current, "stack", "supabase", { chosenRef: LINKED_REF }); - const requests: Array<{ readonly method: string; readonly path: string }> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, (method, path) => - method === "GET" && path === "/v1/projects" - ? Response.json([projectResponse(LINKED_REF)]) - : undefined, - ); - yield* genKeys({ projectRef: Option.none(), overrideName: [] }); - expect(requests).toEqual([{ method: "GET", path: "/v1/projects" }]); - expect(calls).toEqual([{ args: ["gen", "keys", "--project-ref", LINKED_REF] }]); - }).pipe(Effect.scoped, Effect.provide(layer), provideEnv(env)); - }); - - it.effect("keeps the link ahead of the picker in `gen keys`", () => { - const { calls, layer } = setup(tmp.current, "stack", "supabase", { - chosenRef: "zyxwvutsrqponmlkjihg", - }); - return Effect.gen(function* () { - yield* writeStackLink(tmp.current); - yield* genKeys({ projectRef: Option.none(), overrideName: [] }); - expect(calls).toEqual([{ args: ["gen", "keys", "--project-ref", LINKED_REF] }]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect( - "fails `db remote changes` without prompting when stack mode has nothing linked", - () => { - const { calls, layer } = setup(tmp.current, "stack", "supabase", { chosenRef: LINKED_REF }); - const requests: Array<{ readonly method: string; readonly path: string }> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, () => undefined); - const error = yield* dbRemoteChanges(changesFlags()).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "ProjectRefNotLinkedError", - message: PROJECT_NOT_LINKED_MESSAGE, - }); - expect(requests).toEqual([]); - expect(calls).toEqual([]); - }).pipe(Effect.scoped, Effect.provide(layer), provideEnv(env)); - }, - ); - - it.effect("passes an explicit --project-ref to `gen keys` in stack mode", () => { - const { calls, layer } = setup(tmp.current, "stack"); - const explicitRef = "zyxwvutsrqponmlkjihg"; - return Effect.gen(function* () { - yield* writeStackLink(tmp.current); - yield* genKeys({ projectRef: Option.some(explicitRef), overrideName: [] }); - expect(calls).toEqual([{ args: ["gen", "keys", "--project-ref", explicitRef] }]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("forwards the linked pooler URL with the linked ref", () => { - const { calls, poolerUrls, layer } = setup(tmp.current, "stack"); - return Effect.gen(function* () { - yield* writeStackLink(tmp.current, POOLER_URL); - yield* dbRemoteChanges(changesFlags()); - yield* genKeys({ projectRef: Option.some(LINKED_REF), overrideName: [] }); - expect(calls.map((call) => call.args.slice(-2))).toEqual([ - ["--project-ref", LINKED_REF], - ["--project-ref", LINKED_REF], - ]); - expect(poolerUrls).toEqual([POOLER_URL, POOLER_URL]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); - - it.effect("forwards no pooler URL when an explicit --project-ref is not the linked ref", () => { - const { calls, poolerUrls, layer } = setup(tmp.current, "stack"); - const explicitRef = "zyxwvutsrqponmlkjihg"; - return Effect.gen(function* () { - yield* writeStackLink(tmp.current, POOLER_URL); - yield* genKeys({ projectRef: Option.some(explicitRef), overrideName: [] }); - expect(calls).toEqual([{ args: ["gen", "keys", "--project-ref", explicitRef] }]); - expect(poolerUrls).toEqual([undefined]); - }).pipe(Effect.provide(layer), provideEnv(env)); - }); -}); diff --git a/apps/cli/src/command-internal/go-proxy-target-env.ts b/apps/cli/src/command-internal/go-proxy-target-env.ts deleted file mode 100644 index a7ee04a76f..0000000000 --- a/apps/cli/src/command-internal/go-proxy-target-env.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Effect, Option } from "effect"; - -import { RemoteTarget } from "../config/remote-target.service.ts"; -import { remoteTargetRuntimeLayer } from "./management-api-runtime.layer.ts"; -import { softProjectStateMode } from "./project-state-mode.ts"; - -const loadTarget = (projectRef: Option.Option, picker: boolean) => - Effect.gen(function* () { - const remoteTarget = yield* RemoteTarget; - const target = yield* picker ? remoteTarget.resolve(projectRef) : remoteTarget.load(projectRef); - yield* Effect.annotateCurrentSpan({ - "remote_target.source": target.source, - "project.ref": target.ref, - }); - return target; - }); - -/** - * The `--project-ref` argument and env for a Go-delegated command that accepts one: stack mode - * resolves the target in TypeScript and passes its ref explicitly, with the linked pooler URL - * only when the ref is the linked one; legacy mode forwards only the flag the user typed and - * never builds the target runtime. With `picker`, a TTY run with nothing linked prompts for a - * project instead of failing. - */ -export const goProjectTarget = Effect.fn("GoProxy.projectTarget")(function* ( - projectRef: Option.Option, - options: { readonly picker?: boolean } = {}, -) { - if ((yield* softProjectStateMode) !== "stack") { - const env: Record = {}; - return { args: Option.isSome(projectRef) ? ["--project-ref", projectRef.value] : [], env }; - } - const target = yield* loadTarget(projectRef, options.picker === true).pipe( - Effect.provide(remoteTargetRuntimeLayer), - ); - const env: Record = {}; - if (target.poolerUrl !== undefined) env["SUPABASE_INTERNAL_POOLER_URL"] = target.poolerUrl; - return { args: ["--project-ref", target.ref], env }; -}); diff --git a/apps/cli/src/command-internal/go-proxy.layer.ts b/apps/cli/src/command-internal/go-proxy.layer.ts deleted file mode 100644 index 2bcd265e60..0000000000 --- a/apps/cli/src/command-internal/go-proxy.layer.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { Config, Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; -import * as ChildProcess from "effect/process/ChildProcess"; -import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; -import { resolveWorkdir } from "../config/command-settings.layer.ts"; -import { CLI_VERSION } from "../shared/cli/version.ts"; -import { ProcessControl } from "../shared/runtime/process-control.service.ts"; -import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; -import { GoChildExitError } from "./go-child-exit.error.ts"; -import { GoProxyInvocation } from "./go-proxy-invocation.ts"; -import { GoProxy } from "./go-proxy.service.ts"; -import { readProjectLinkFile } from "./project-link.ts"; -import { usesStackState } from "./stack-backend.ts"; -import { withProcessSpan } from "../shared/telemetry/spans.ts"; - -const markDelegated = Effect.serviceOption(GoProxyInvocation).pipe( - Effect.flatMap((invocation) => - Option.isSome(invocation) ? invocation.value.markDelegated : Effect.void, - ), -); - -/** - * Tells the Go child to leave `supabase/.temp` alone when the project keeps its state in - * `$SUPABASE_HOME`, and hands it the linked project's identity as the JSON of its - * `linked-project.json` record so it can still attribute telemetry. Read from the calling fiber, - * like `markDelegated`. - */ -const makeStackStateEnv = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: Effect.Effect, -): Effect.Effect> => - Effect.gen(function* (): Effect.fn.Return> { - if (!(yield* usesStackState)) return {}; - const link = yield* readProjectLinkFile(fs, path, yield* workdir).pipe( - Effect.orElseSucceed(() => Option.none()), - ); - if (Option.isNone(link)) return { SUPABASE_INTERNAL_STACK_STATE: "1" }; - const { project } = link.value; - return { - SUPABASE_INTERNAL_STACK_STATE: "1", - SUPABASE_INTERNAL_LINKED_PROJECT: JSON.stringify({ - ref: project.ref, - name: project.name ?? "", - organization_id: project.organization_id ?? "", - organization_slug: project.organization_slug ?? "", - }), - }; - }); - -const PLATFORM_CANDIDATES: Partial>>>> = - { - darwin: { arm64: ["darwin-arm64"], x64: ["darwin-x64"] }, - linux: { - arm64: ["linux-arm64", "linux-arm64-musl"], - x64: ["linux-x64", "linux-x64-musl"], - }, - win32: { arm64: ["windows-arm64"], x64: ["windows-x64"] }, - }; - -const require = createRequire(import.meta.url); - -/** - * Outcome of looking up `supabase-go`. `notFound` carries every location checked, so the error can - * be specific about what was tried, and callers never silently fall back to `supabase` on PATH - * (which would resolve to this shim itself and fork-bomb). - */ -export type BinaryResolution = - | { readonly found: string } - | { readonly notFound: ReadonlyArray }; - -function resolveBinary(): BinaryResolution { - const tried: string[] = []; - - const envBin = process.env["SUPABASE_GO_BINARY"]; - if (envBin) return { found: envBin }; - tried.push("$SUPABASE_GO_BINARY (unset)"); - - const ext = process.platform === "win32" ? ".exe" : ""; - - // When running as a compiled standalone binary (exec'd by the base shim), process.execPath is - // this binary's own path; look for supabase-go co-located next to it. - const colocated = path.join(path.dirname(process.execPath), `supabase-go${ext}`); - if (existsSync(colocated)) return { found: colocated }; - tried.push(`${colocated} (not found alongside the shim)`); - - // When running from source, resolve via installed npm packages. - // Guard with existsSync — in dev the workspace stub packages exist but their bin/ is empty. - const candidates = PLATFORM_CANDIDATES[process.platform]?.[os.arch()] ?? []; - for (const suffix of candidates) { - try { - const pkgPath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)); - const bin = path.join(pkgPath, "bin", `supabase-go${ext}`); - if (existsSync(bin)) return { found: bin }; - tried.push(`${bin} (npm package present, binary missing)`); - } catch { - tried.push(`@supabase/cli-${suffix} (npm package not installed)`); - } - } - - return { notFound: tried }; -} - -/** - * Builds a `curl | tar` install snippet for the host platform, using the version baked into this - * shim at build time. Returns null when there's no concrete release URL (dev build) or the host - * arch isn't one the release pipeline targets. - */ -function reinstallTarballSnippet(): ReadonlyArray | null { - if (CLI_VERSION === "0.0.0-dev") return null; - const archSuffix = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : null; - if (archSuffix === null) return null; - // Node's `process.platform` is `win32`; GitHub release assets use the modern `windows` slug. - const osSlug = process.platform === "win32" ? "windows" : process.platform; - const asset = `supabase_${CLI_VERSION}_${osSlug}_${archSuffix}.tar.gz`; - return [ - ` mkdir -p "$HOME/.local/share/supabase"`, - ` curl -sL https://github.com/supabase/cli/releases/download/v${CLI_VERSION}/${asset} \\`, - ` | tar -xzf - -C "$HOME/.local/share/supabase"`, - ` export PATH="$HOME/.local/share/supabase:$PATH"`, - ]; -} - -export function formatGoBinaryNotFoundError(tried: ReadonlyArray): string { - const snippet = reinstallTarballSnippet(); - return [ - "Could not find the `supabase-go` binary.", - "", - "The Supabase CLI ships as two co-located binaries: `supabase` (this shim)", - "and `supabase-go` (the Go CLI that the shim forwards to). The shim looked", - "for `supabase-go` in:", - "", - ...tried.map((line) => ` • ${line}`), - "", - "To fix, do one of:", - " • Extract the release tarball into a directory and add the directory to", - " PATH (do not move `supabase` somewhere `supabase-go` doesn't follow).", - ...(snippet === null ? [] : [" For example, on this host:", "", ...snippet, ""]), - " • Install via npm: `npm i -g supabase`.", - " • Set SUPABASE_GO_BINARY to the absolute path of `supabase-go`.", - ].join("\n"); -} - -/** - * Creates a GoProxy layer. - * - * In production use `goProxyLayer` (no options). - * - * In tests pass `{ cwd, env }` to run the binary in an isolated directory - * with a controlled SUPABASE_HOME, so tests don't pollute the real home dir: - * - * makeGoProxyLayer({ - * cwd: projectDir, - * env: { SUPABASE_HOME: homeDir, SUPABASE_NO_KEYRING: "1", SUPABASE_TELEMETRY_DISABLED: "1" }, - * }) - * - * @public - */ -export function makeGoProxyLayer(opts?: { - cwd?: string; - /** - * Extra env for every spawned child. - */ - env?: Record; - globalArgs?: ReadonlyArray; - /** - * The `--workdir` the user passed, which locates the project link handed to stack-mode children. - */ - workdir?: Option.Option; - /** - * Let the parent emit its success tail after re-emitting captured stdout. - */ - parentOwnsCapturedSuccessTail?: boolean; - /** - * Override binary resolution. Primarily a test seam so specs don't have to - * mutate `process.env.SUPABASE_GO_BINARY` or stub the filesystem: - * - `string` — treat as the resolved Go binary path. - * - `{ notFound: [...] }` — simulate the not-found path; `.exec` will print - * the diagnostic and fail with a non-zero exit code. - * - * In production, leave unset and let `resolveBinary()` pick the right - * artifact for the host platform. - */ - binary?: string | BinaryResolution; -}): Layer.Layer< - GoProxy, - never, - ProcessControl | ChildProcessSpawner | FileSystem.FileSystem | Path.Path | RuntimeInfo -> { - return Layer.effect( - GoProxy, - Effect.gen(function* () { - const processControl = yield* ProcessControl; - const spawner = yield* ChildProcessSpawner; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const runtimeInfo = yield* RuntimeInfo; - const resolved: BinaryResolution = - typeof opts?.binary === "string" - ? { found: opts.binary } - : (opts?.binary ?? resolveBinary()); - const globalArgs = opts?.globalArgs ?? []; - const projectWorkdir = Effect.gen(function* () { - const envValue = yield* Config.option(Config.String("SUPABASE_WORKDIR")).pipe( - Effect.orElseSucceed(() => Option.none()), - ); - const { workdir } = yield* resolveWorkdir( - opts?.workdir ?? Option.none(), - envValue, - opts?.cwd ?? runtimeInfo.cwd, - (candidate) => fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)), - path, - ); - return workdir; - }); - const stackStateEnv = makeStackStateEnv(fs, path, projectWorkdir); - - return GoProxy.of({ - exec: (args, execOpts) => - Effect.scoped( - Effect.gen(function* () { - if (!("found" in resolved)) { - yield* Effect.sync(() => { - process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); - }); - return yield* Effect.fail( - new GoChildExitError({ - exitCode: 1, - message: "supabase-go binary not found", - }), - ); - } - const binary = resolved.found; - - // Hold terminal signals on the parent for the child's lifetime: the child spawner - // defaults to `detached: true` on non-Windows, which would put the child in its own - // process group and miss tty signals, so `detached: false` below lets Ctrl+C reach - // the Go binary directly. Without a listener, Bun/Node would also default-terminate - // the parent on SIGINT before the child's real exit code is known. - yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - // Only an instrumented caller that delegates the whole command suppresses child - // telemetry: the parent already emits `cli_command_executed` there. Pure proxy - // commands have no parent event, so the child must stay free to report. - const env = { - ...opts?.env, - ...execOpts?.env, - ...(execOpts?.suppressChildTelemetry === true - ? { SUPABASE_TELEMETRY_DISABLED: "1" } - : {}), - ...(yield* stackStateEnv), - }; - const argv = [...globalArgs, ...args]; - const exitCode = yield* withProcessSpan( - "GoProxy.exec", - { executable: binary, argCount: argv.length }, - (traceEnv) => - spawner - .exitCode( - ChildProcess.make(binary, argv, { - cwd: execOpts?.cwd ?? opts?.cwd, - env: { ...env, ...traceEnv }, - extendEnv: true, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - detached: false, - }), - ) - .pipe(Effect.orDie), - ); - if (exitCode !== 0) { - return yield* Effect.fail( - new GoChildExitError({ - exitCode, - message: `supabase-go exited with code ${exitCode} (see stderr for details)`, - }), - ); - } - yield* markDelegated; - }), - ), - execCapture: (args, execOpts) => - Effect.scoped( - Effect.gen(function* () { - if (!("found" in resolved)) { - yield* Effect.sync(() => { - process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); - }); - return yield* Effect.fail( - new GoChildExitError({ - exitCode: 1, - message: "supabase-go binary not found", - }), - ); - } - const binary = resolved.found; - yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - // Same rule as `exec`: only an instrumented caller that owns the - // parent `cli_command_executed` event suppresses child telemetry. - const env = { - ...opts?.env, - ...execOpts?.env, - ...(execOpts?.suppressChildTelemetry === true - ? { SUPABASE_TELEMETRY_DISABLED: "1" } - : {}), - ...(opts?.parentOwnsCapturedSuccessTail === true - ? { SUPABASE_NO_UPDATE_NOTIFIER: "1" } - : {}), - ...(yield* stackStateEnv), - }; - // Capture stdout while keeping stderr inherited, so progress still reaches the user - // while stdout is collected for wrapping. Callers pass stdin: "ignore" to give the - // child a non-TTY stdin so it can't block on a prompt before the wrapper emits its - // machine-output envelope. - const argv = [...globalArgs, ...args]; - const { captured, exitCode } = yield* withProcessSpan( - "GoProxy.execCapture", - { executable: binary, argCount: argv.length }, - (traceEnv) => - Effect.gen(function* () { - const handle = yield* spawner - .spawn( - ChildProcess.make(binary, argv, { - cwd: execOpts?.cwd ?? opts?.cwd, - env: { ...env, ...traceEnv }, - extendEnv: true, - stdin: execOpts?.stdin ?? "inherit", - stdout: "pipe", - stderr: "inherit", - detached: false, - }), - ) - .pipe(Effect.orDie); - // Drain stdout fully before awaiting exit so a full pipe buffer can't - // deadlock the child. - const captured = yield* Stream.mkString(Stream.decodeText(handle.stdout)).pipe( - Effect.orDie, - ); - const exitCode = yield* handle.exitCode.pipe(Effect.orDie); - return { captured, exitCode }; - }), - (result) => result.exitCode, - ); - if (exitCode !== 0) { - return yield* Effect.fail( - new GoChildExitError({ - exitCode, - message: `supabase-go exited with code ${exitCode} (see stderr for details)`, - }), - ); - } - if (opts?.parentOwnsCapturedSuccessTail !== true) { - yield* markDelegated; - } - return captured; - }), - ), - }); - }), - ); -} diff --git a/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts b/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts deleted file mode 100644 index 61bfd1cdad..0000000000 --- a/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { describe, expect, it, vi } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Layer, - Path, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/process"; -import { - type CliProcessSignal, - ProcessControl, -} from "../shared/runtime/process-control.service.ts"; -import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; -import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; -import { GoChildExitError } from "./go-child-exit.error.ts"; -import { GoProxyInvocation, goProxyInvocationLayer } from "./go-proxy-invocation.ts"; -import { GoProxy } from "./go-proxy.service.ts"; -import { projectStateModeLayer } from "./project-state-mode.ts"; -import { stackBackendLayer } from "./stack-backend.ts"; -import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer.ts"; - -/** - * Regression tests for SIGINT propagation: Ctrl+C on a proxied long-running command must reach - * the Go sidecar and not lose its exit code. `ChildProcess.make` must be called with - * `detached: false`, and `processControl.holdSignals` must be acquired before spawn (covering - * SIGINT/SIGTERM/SIGHUP) and released on every exit path. - */ - -type CapturedCommand = { - command: string; - args: readonly string[]; - options: { - detached?: boolean; - stdin?: unknown; - stdout?: unknown; - stderr?: unknown; - cwd?: string; - env?: Record; - extendEnv?: boolean; - }; -}; - -type ExitBehavior = - | { kind: "success"; code: number } - | { kind: "never" } - | { kind: "fail"; error: string }; - -type HoldEvent = - | { kind: "acquire"; id: number; signals: ReadonlyArray } - | { kind: "release"; id: number }; - -/** - * Records holdSignals(...) acquire/release transitions. Each acquire gets a monotonically - * increasing id so tests can pair an acquire with its release. - * - * `exit()` here only guards against a regression that reintroduces a direct - * `ProcessControl.exit()` call; it blocks on `Effect.never` since nothing in this file exercises - * it. - */ -function mockProcessControl() { - const holdEvents: HoldEvent[] = []; - const exitCalls: number[] = []; - let nextHoldId = 0; - - const exit = (code: number) => - Effect.sync(() => { - exitCalls.push(code); - }).pipe(Effect.flatMap(() => Effect.never)); - - return { - get holdEvents() { - return holdEvents; - }, - get exitCalls() { - return exitCalls; - }, - layer: Layer.succeed( - ProcessControl, - ProcessControl.of({ - awaitSignal: () => Effect.never, - awaitShutdown: Effect.never, - holdSignals: (signals) => - Effect.acquireRelease( - Effect.sync(() => { - const id = nextHoldId++; - holdEvents.push({ kind: "acquire", id, signals }); - return id; - }), - (id) => - Effect.sync(() => { - holdEvents.push({ kind: "release", id }); - }), - ).pipe(Effect.asVoid), - exit, - setExitCode: () => Effect.void, - getExitCode: Effect.succeed(undefined), - }), - ), - }; -} - -/** - * Builds a mock `ChildProcessSpawner` that records every spawned command and returns a - * controllable exit code. `spawnedBeforeExit` resolves as soon as the spawn is observed, to - * sequence a race-then-interrupt. - */ -function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred) { - const spawned: CapturedCommand[] = []; - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command: any) => - Effect.sync(() => { - const cmd = command as CapturedCommand & { _tag: string }; - spawned.push({ - command: cmd.command, - args: cmd.args, - options: cmd.options, - }); - if (spawnedBeforeExit !== undefined) { - Deferred.doneUnsafe(spawnedBeforeExit, Effect.void); - } - const exitCode = - exit.kind === "success" - ? Effect.succeed(ChildProcessSpawner.ExitCode(exit.code)) - : exit.kind === "never" - ? Effect.never - : Effect.fail(new Error(exit.error) as any); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(42_424), - exitCode, - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain as any, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain as any, - getOutputFd: () => Stream.empty, - }); - }), - ), - ); - return { layer, spawned }; -} - -/** Injected directly via `makeGoProxyLayer({ binary })` so tests don't depend on workspace package state or `process.env`. */ -const TEST_BINARY = "/test/fake-supabase-go"; - -describe("formatGoBinaryNotFoundError", () => { - const TRIED = [ - "$SUPABASE_GO_BINARY (unset)", - "/usr/local/bin/supabase-go (not found alongside the shim)", - "@supabase/cli-linux-x64 (npm package not installed)", - ]; - - it("renders each tried location as a bullet and includes remediation hints", () => { - const message = formatGoBinaryNotFoundError(TRIED); - expect(message).toContain("Could not find the `supabase-go` binary"); - expect(message).toContain(" • $SUPABASE_GO_BINARY (unset)"); - expect(message).toContain(" • /usr/local/bin/supabase-go (not found alongside the shim)"); - expect(message).toContain(" • @supabase/cli-linux-x64 (npm package not installed)"); - expect(message).toContain("npm i -g supabase"); - expect(message).toContain("SUPABASE_GO_BINARY"); - }); - - it("omits the curl|tar snippet on dev builds (no CLI_VERSION baked in)", () => { - const message = formatGoBinaryNotFoundError(TRIED); - expect(message).not.toContain("curl -sL"); - expect(message).toContain("Extract the release tarball"); - }); -}); - -// Instantiates a fresh module with a stubbed CLI_VERSION to assert against a known release -// version + asset filename; nested in its own describe so the module mock doesn't bleed into -// other suites. -describe("formatGoBinaryNotFoundError - pinned snippet", () => { - const TRIED = ["$SUPABASE_GO_BINARY (unset)"]; - const PINNED_VERSION = "2.100.0"; - - async function withMockedHost( - opts: { platform: NodeJS.Platform; arch: NodeJS.Architecture }, - fn: (mod: typeof import("./go-proxy.layer.ts")) => void | Promise, - ): Promise { - vi.resetModules(); - vi.doMock("../shared/cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); - const originalPlatform = process.platform; - const originalArch = process.arch; - Object.defineProperty(process, "platform", { value: opts.platform, configurable: true }); - Object.defineProperty(process, "arch", { value: opts.arch, configurable: true }); - try { - const mod = await import("./go-proxy.layer.ts"); - await fn(mod); - } finally { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); - vi.doUnmock("../cli/version.ts"); - vi.resetModules(); - } - } - - it("renders a copy-pasteable install snippet for linux x64", async () => { - await withMockedHost({ platform: "linux", arch: "x64" }, (mod) => { - const message = mod.formatGoBinaryNotFoundError(TRIED); - expect(message).toContain( - `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_linux_amd64.tar.gz`, - ); - expect(message).toContain(`mkdir -p "$HOME/.local/share/supabase"`); - expect(message).toContain(`export PATH="$HOME/.local/share/supabase:$PATH"`); - }); - }); - - it("maps Node's win32 platform to the release asset's `windows` slug", async () => { - await withMockedHost({ platform: "win32", arch: "x64" }, (mod) => { - const message = mod.formatGoBinaryNotFoundError(TRIED); - expect(message).toContain( - `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_windows_amd64.tar.gz`, - ); - expect(message).not.toContain("win32"); - }); - }); - - it("maps darwin arm64 to the matching release asset", async () => { - await withMockedHost({ platform: "darwin", arch: "arm64" }, (mod) => { - expect(mod.formatGoBinaryNotFoundError(TRIED)).toContain( - `supabase_${PINNED_VERSION}_darwin_arm64.tar.gz`, - ); - }); - }); - - it("omits the snippet on unsupported architectures (no release asset)", async () => { - await withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => { - expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); - }); - }); -}); - -const proxyDeps = ( - spawner: Layer.Layer, - processControl: Layer.Layer, -) => Layer.mergeAll(BunServices.layer, mockRuntimeInfo(), spawner, processControl); - -describe("makeGoProxyLayer", () => { - const tmp = useTempWorkdir("supabase-go-proxy-"); - - it.effect("records delegated execution for the parent success trailer", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = Layer.mergeAll( - makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ), - goProxyInvocationLayer, - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const invocation = yield* GoProxyInvocation; - - expect(yield* invocation.wasDelegated).toBe(false); - yield* proxy.exec(["migration", "squash", "--local"]); - expect(yield* invocation.wasDelegated).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("leaves captured success tails to the parent when configured", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = Layer.mergeAll( - makeGoProxyLayer({ - binary: TEST_BINARY, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - parentOwnsCapturedSuccessTail: true, - }).pipe(Layer.provide(proxyDeps(spawner.layer, pc.layer))), - goProxyInvocationLayer, - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const invocation = yield* GoProxyInvocation; - - yield* proxy.execCapture(["db", "diff"], { - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - }); - - expect(spawner.spawned[0]?.options.env?.SUPABASE_NO_UPDATE_NOTIFIER).toBe("1"); - expect(yield* invocation.wasDelegated).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("passes detached:false and inherited stdio to the spawner", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY, globalArgs: ["--debug"] }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["projects", "list"]); - - expect(spawner.spawned).toHaveLength(1); - const captured = spawner.spawned[0]!; - expect(captured.command).toBe(TEST_BINARY); - expect(captured.args).toEqual(["--debug", "projects", "list"]); - expect(captured.options.detached).toBe(false); - expect(captured.options.stdin).toBe("inherit"); - expect(captured.options.stdout).toBe("inherit"); - expect(captured.options.stderr).toBe("inherit"); - expect(captured.options.extendEnv).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("leaves child telemetry enabled for pure proxy commands", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["migration", "squash"]); - yield* proxy.execCapture(["gen", "keys"]); - - for (const captured of spawner.spawned) { - expect(captured.options.env ?? {}).not.toHaveProperty("SUPABASE_TELEMETRY_DISABLED"); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("suppresses child telemetry when the caller owns the parent event", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["db", "pull"], { suppressChildTelemetry: true }); - yield* proxy.execCapture(["db", "diff"], { - env: { CUSTOM: "kept" }, - suppressChildTelemetry: true, - }); - - for (const captured of spawner.spawned) { - expect(captured.options.env).toMatchObject({ SUPABASE_TELEMETRY_DISABLED: "1" }); - } - expect(spawner.spawned[1]?.options.env).toMatchObject({ CUSTOM: "kept" }); - }).pipe(Effect.provide(layer)); - }); - - it.effect( - "tells children to leave supabase/.temp alone and who is linked in stack mode only", - () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY, cwd: tmp.current }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - const run = (modeLayer: Layer.Layer | undefined) => - Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["db", "remote", "changes"]); - yield* proxy.execCapture(["db", "diff"]); - }).pipe(Effect.provide(modeLayer === undefined ? layer : Layer.merge(layer, modeLayer))); - const childEnv = () => - spawner.spawned.splice(0).map((captured) => ({ - stackState: captured.options.env?.SUPABASE_INTERNAL_STACK_STATE, - linked: captured.options.env?.SUPABASE_INTERNAL_LINKED_PROJECT, - pooler: captured.options.env?.SUPABASE_INTERNAL_POOLER_URL, - })); - - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const unlinked = { stackState: "1", linked: undefined, pooler: undefined }; - const absent = { stackState: undefined, linked: undefined, pooler: undefined }; - - yield* run(projectStateModeLayer("stack")); - expect(childEnv()).toEqual([unlinked, unlinked]); - - yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); - const writeLink = (poolerUrl?: string) => - fs.writeFileString( - path.join(tmp.current, "supabase", ".link.json"), - JSON.stringify({ - project: { - ref: "abcdefghijklmnopqrst", - name: "Acme", - organization_id: "org_123", - organization_slug: "acme", - }, - active_branch: { - ref: "tsrqponmlkjihgfedcba", - name: "feature-x", - is_default: false, - ...(poolerUrl === undefined ? {} : { pooler_url: poolerUrl }), - }, - fetchedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const linked = { - stackState: "1", - linked: JSON.stringify({ - ref: "abcdefghijklmnopqrst", - name: "Acme", - organization_id: "org_123", - organization_slug: "acme", - }), - pooler: undefined, - }; - yield* writeLink(); - yield* run(projectStateModeLayer("stack")); - yield* run(stackBackendLayer("stack")); - expect(childEnv()).toEqual([linked, linked, linked, linked]); - - const poolerUrl = - "postgresql://postgres.tsrqponmlkjihgfedcba:[YOUR-PASSWORD]@pooler.example.com:6543/postgres"; - yield* writeLink(poolerUrl); - yield* run(projectStateModeLayer("stack")); - expect(childEnv()).toEqual([linked, linked]); - - yield* run(projectStateModeLayer("legacy")); - yield* run(undefined); - expect(childEnv()).toEqual([absent, absent, absent, absent]); - }).pipe(Effect.provide(BunServices.layer)); - }, - ); - - it.effect("passes the layer's env to children, letting per-call env win", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ - binary: TEST_BINARY, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" }, - }).pipe(Layer.provide(proxyDeps(spawner.layer, pc.layer))); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["projects", "list"]); - yield* proxy.execCapture(["gen", "keys"]); - yield* proxy.exec(["projects", "list"], { env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" } }); - - expect(spawner.spawned[0]?.options.env?.SUPABASE_NO_UPDATE_NOTIFIER).toBe("1"); - expect(spawner.spawned[1]?.options.env?.SUPABASE_NO_UPDATE_NOTIFIER).toBe("1"); - expect(spawner.spawned[2]?.options.env?.SUPABASE_NO_UPDATE_NOTIFIER).toBe("0"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("propagates non-zero exit codes via GoChildExitError", () => { - const spawner = mockSpawner({ kind: "success", code: 7 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const exit = yield* proxy.exec(["some", "command"]).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(GoChildExitError); - expect((error as GoChildExitError).exitCode).toBe(7); - } - expect(pc.exitCalls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("lets an Effect.ensuring finalizer run after a non-zero exit (CLI-1879)", () => { - const spawner = mockSpawner({ kind: "success", code: 5 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - let finalizerRan = false; - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["some", "command"]).pipe( - Effect.ensuring( - Effect.sync(() => { - finalizerRan = true; - }), - ), - Effect.exit, - ); - expect(finalizerRan).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("does not call ProcessControl.exit when the Go binary exits zero", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec(["some", "command"]); - expect(pc.exitCalls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("calls holdSignals with SIGINT+SIGTERM+SIGHUP before spawning", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec([]); - - const acquires = pc.holdEvents.filter((e) => e.kind === "acquire"); - expect(acquires).toHaveLength(1); - expect(acquires[0]!.signals).toEqual(["SIGINT", "SIGTERM", "SIGHUP"]); - - expect(spawner.spawned).toHaveLength(1); - expect(pc.holdEvents[0]).toEqual(expect.objectContaining({ kind: "acquire" })); - }).pipe(Effect.provide(layer)); - }); - - it.effect("releases the holdSignals scope on successful exec", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - yield* proxy.exec([]); - - expect(pc.holdEvents).toEqual([ - { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - { kind: "release", id: 0 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("releases the holdSignals scope when the spawner fails", () => { - const spawner = mockSpawner({ kind: "fail", error: "spawn failed" }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - // spawner failures are Effect.orDie'd, so we swallow the defect here. - yield* proxy.exec([]).pipe(Effect.exit); - - expect(pc.holdEvents).toContainEqual({ kind: "release", id: 0 }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("releases the holdSignals scope when the fiber is interrupted", () => { - const spawned = Deferred.makeUnsafe(); - const spawner = mockSpawner({ kind: "never" }, spawned); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const fiber = yield* proxy.exec([]).pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(spawned); - - expect(pc.holdEvents).toEqual([ - { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - ]); - - yield* Fiber.interrupt(fiber); - - expect(pc.holdEvents).toEqual([ - { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - { kind: "release", id: 0 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect( - "prints a diagnostic and fails with exit code 1 when supabase-go cannot be resolved", - () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - const tried = [ - "$SUPABASE_GO_BINARY (unset)", - "/usr/local/bin/supabase-go (not found alongside the shim)", - ]; - const layer = makeGoProxyLayer({ binary: { notFound: tried } }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const exit = yield* proxy.exec(["db", "start"]).pipe(Effect.exit); - - expect(spawner.spawned).toHaveLength(0); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(GoChildExitError); - expect((error as GoChildExitError).exitCode).toBe(1); - } - expect(pc.exitCalls).toEqual([]); - expect(stderr).toHaveBeenCalledTimes(1); - const written = String(stderr.mock.calls[0]![0]); - expect(written).toContain("Could not find the `supabase-go` binary"); - expect(written).toContain("$SUPABASE_GO_BINARY (unset)"); - expect(written).toContain("/usr/local/bin/supabase-go"); - expect(written).toContain("SUPABASE_GO_BINARY"); - stderr.mockRestore(); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "execCapture also prints a diagnostic and fails with exit code 1 when supabase-go cannot be resolved", - () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - const tried = ["$SUPABASE_GO_BINARY (unset)"]; - const layer = makeGoProxyLayer({ binary: { notFound: tried } }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - const exit = yield* proxy.execCapture(["db", "dump"]).pipe(Effect.exit); - - expect(spawner.spawned).toHaveLength(0); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(GoChildExitError); - expect((error as GoChildExitError).exitCode).toBe(1); - } - expect(pc.exitCalls).toEqual([]); - expect(stderr).toHaveBeenCalledTimes(1); - stderr.mockRestore(); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("opens and closes a fresh hold scope per sequential exec call", () => { - const spawner = mockSpawner({ kind: "success", code: 0 }); - const pc = mockProcessControl(); - const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( - Layer.provide(proxyDeps(spawner.layer, pc.layer)), - ); - return Effect.gen(function* () { - const proxy = yield* GoProxy; - for (let i = 0; i < 3; i++) { - yield* proxy.exec([`call-${i}`]); - } - - expect(pc.holdEvents).toEqual([ - { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - { kind: "release", id: 0 }, - { kind: "acquire", id: 1, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - { kind: "release", id: 1 }, - { kind: "acquire", id: 2, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, - { kind: "release", id: 2 }, - ]); - expect(spawner.spawned).toHaveLength(3); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/cli/src/command-internal/go-proxy.service.ts b/apps/cli/src/command-internal/go-proxy.service.ts deleted file mode 100644 index 452fc2c51c..0000000000 --- a/apps/cli/src/command-internal/go-proxy.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Effect } from "effect"; -import { Context } from "effect"; -import type { GoChildExitError } from "./go-child-exit.error.ts"; - -interface GoProxyShape { - /** - * Forwards args to the Go binary, inheriting stdio and propagating the exit code. Fails with - * `GoChildExitError` (carrying the exact exit code) on a non-zero exit or an unresolvable - * binary. - * - * `opts.suppressChildTelemetry` disables telemetry in the child; set it only when the caller's - * own command instrumentation already emits `cli_command_executed`, since a pure proxy - * handler's Go child is its only telemetry emitter. - */ - readonly exec: ( - args: ReadonlyArray, - opts?: { - readonly cwd?: string; - readonly env?: Record; - readonly suppressChildTelemetry?: boolean; - }, - ) => Effect.Effect; - - /** - * Like `exec`, but captures the child's stdout and returns it as a string instead of - * inheriting it; stderr stays inherited. Fails with `GoChildExitError` the same way `exec` - * does. - * - * `opts.stdin: "ignore"` gives the child a non-TTY stdin so a prompt (Go's `PromptYesNo`) - * takes its default instead of blocking — required when a machine-output caller delegates a - * command that would otherwise prompt before the JSON envelope is emitted. - */ - readonly execCapture: ( - args: ReadonlyArray, - opts?: { - readonly cwd?: string; - readonly env?: Record; - readonly stdin?: "inherit" | "ignore"; - readonly suppressChildTelemetry?: boolean; - }, - ) => Effect.Effect; -} - -export class GoProxy extends Context.Service()("supabase/cli/GoProxy") {} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts new file mode 100644 index 0000000000..6193c832b2 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts @@ -0,0 +1,141 @@ +import { fileURLToPath } from "node:url"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { describe, expect, it } from "vitest"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readTargetPolicy } from "../target/target-policy.ts"; + +const srcDir = fileURLToPath(new URL("../..", import.meta.url)); + +const VARIANTS: ReadonlyArray = [ + undefined, + { stackBackend: "stack", computeEnabled: true }, +]; + +const consentDefaultByLeaf: ReadonlyMap = new Map( + VARIANTS.flatMap((variant) => + [...commandTreeFor(variant)].flatMap(([path, node]) => { + const policy = readTargetPolicy(node.command); + return node.isLeaf && Option.isSome(policy) && policy.value.consent !== "none" + ? [[path, policy.value.consent] as const] + : []; + }), + ), +); + +const consentLeafPaths: ReadonlySet = new Set(consentDefaultByLeaf.keys()); + +const LITERAL_CALL = /\bdescribeHostedConsent\(\s*"([^"]+)"/g; +const ANY_CALL = /\bdescribeHostedConsent\(/g; + +const testSources = await Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files = yield* fs.readDirectory(srcDir, { recursive: true }); + return yield* Effect.forEach( + files.filter((file) => file.endsWith(".test.ts")), + (file) => + Effect.map(fs.readFileString(path.join(srcDir, file)), (source) => ({ + file: file.split(path.sep).join("/"), + source, + })), + ); +}).pipe(Effect.provide(BunServices.layer), Effect.runPromise); + +const callsByLeaf = new Map>(); +const nonLiteralCallSites: Array = []; + +for (const { file, source } of testSources) { + const literalCalls = [...source.matchAll(LITERAL_CALL)]; + for (const [, leaf] of literalCalls) { + callsByLeaf.set(leaf!, [...(callsByLeaf.get(leaf!) ?? []), file]); + } + const anyCalls = [...source.matchAll(ANY_CALL)].length; + if (anyCalls !== literalCalls.length) { + nonLiteralCallSites.push(`${file} (${anyCalls - literalCalls.length})`); + } +} + +describe("hosted consent coverage", () => { + it("scans real test files and finds consenting leaves in the command tree", () => { + expect(testSources.length).toBeGreaterThan(0); + expect(consentLeafPaths.size).toBeGreaterThan(0); + }); + + it("has a consent matrix for every leaf whose policy asks for consent", () => { + const missing = [...consentLeafPaths].filter((path) => !callsByLeaf.has(path)).sort(); + expect(missing, "leaves with consent but no hosted-consent matrix in src/**/*.test.ts").toEqual( + [], + ); + }); + + it("has no consent matrix for a path that is not a leaf with consent", () => { + const extra = [...callsByLeaf] + .filter(([path]) => !consentLeafPaths.has(path)) + .map(([path, files]) => `${path} (${files.join(", ")})`) + .sort(); + expect(extra, "hosted-consent matrices for paths that are not consenting leaves").toEqual([]); + }); + + it("names the leaf as a string literal at every call site so the scan can see it", () => { + expect(nonLiteralCallSites).toEqual([]); + }); + + it("defaults the prompt to Yes for create-only leaves and No for everything else", () => { + expect(Object.fromEntries(consentDefaultByLeaf)).toMatchInlineSnapshot(` + { + "backups restore": "no", + "bootstrap": "yes", + "branches create": "yes", + "branches delete": "no", + "branches disable": "no", + "branches pause": "no", + "branches unpause": "no", + "branches update": "no", + "compute delete": "no", + "compute push": "no", + "config push": "no", + "db pull": "no", + "db push": "no", + "db query": "no", + "db reset": "no", + "db test": "no", + "domains activate": "no", + "domains create": "yes", + "domains delete": "no", + "encryption update-root-key": "no", + "feedback delete": "no", + "functions delete": "no", + "functions deploy": "no", + "migration down": "no", + "migration repair": "no", + "migration squash": "no", + "migration up": "no", + "network-bans remove": "no", + "network-restrictions update": "no", + "notebooks push": "no", + "orgs create": "yes", + "postgres-config delete": "no", + "postgres-config update": "no", + "projects create": "yes", + "projects delete": "no", + "pull": "no", + "secrets set": "no", + "secrets unset": "no", + "seed buckets": "no", + "ssl-enforcement update": "no", + "sso add": "no", + "sso remove": "no", + "sso update": "no", + "storage cp": "no", + "storage mv": "no", + "storage rm": "no", + "test db": "no", + "vanity-subdomains activate": "no", + "vanity-subdomains delete": "no", + } + `); + }); +}); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts new file mode 100644 index 0000000000..9b0ad58403 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** A hosted mutation needs confirmation and this run can neither prompt nor see `--yes`. */ +export class HostedMutationConsentRequiredError extends Data.TaggedError( + "HostedMutationConsentRequiredError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.consentRequired; + } +} + +/** The user answered no to a hosted-mutation confirmation. */ +export class HostedMutationDeclinedError extends Data.TaggedError("HostedMutationDeclinedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts new file mode 100644 index 0000000000..91cbaf3097 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts @@ -0,0 +1,47 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import { isMachineResourceFormat } from "../../shared/output/resource-format.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { resolveAgentMode } from "../agent-mode.ts"; +import { AgentFlag, OutputFlag } from "../global-flags.ts"; + +export interface PromptBlocker { + readonly reason: string; + readonly agent: boolean; +} + +const agentDetected = Effect.gen(function* () { + const flag = Option.getOrElse(yield* Effect.serviceOption(AgentFlag), () => "auto" as const); + const tool = yield* Effect.serviceOption(AiTool); + return resolveAgentMode( + flag, + Option.flatMap(tool, (detected) => detected.name), + ); +}); + +/** + * Why this run cannot ask a question, or `None` when it can: text output on an interactive + * stdout with a TTY stdin, no machine `-o` format, and no agent driving it. + */ +export const promptBlocker = Effect.gen(function* () { + const output = yield* Output; + const tty = yield* Tty; + const outputFlag = yield* OutputFlag; + if (yield* agentDetected) { + return Option.some({ reason: "it is running in agent mode", agent: true }); + } + const blocked = (reason: string) => Option.some({ reason, agent: false }); + if (output.format !== "text") return blocked(`its output format is ${output.format}`); + if (isMachineResourceFormat(outputFlag)) { + return blocked( + `it writes machine-readable -o ${Option.getOrElse(outputFlag, () => "")} output`, + ); + } + if (!output.interactive) return blocked("stdout is not a terminal"); + if (!tty.stdinIsTty) return blocked("stdin is not a terminal"); + return Option.none(); +}); + +export const canPrompt = Effect.map(promptBlocker, Option.isNone); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts new file mode 100644 index 0000000000..3d4577c6b7 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts @@ -0,0 +1,8 @@ +declare const Brand: unique symbol; + +/** Proof that the user approved a hosted mutation; only the consent module constructs one. */ +export interface HostedMutationConsent { + readonly [Brand]: true; + readonly action: string; + readonly via: "prompt" | "yes"; +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts new file mode 100644 index 0000000000..c017622f3b --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -0,0 +1,144 @@ +import { Effect, Option } from "effect"; + +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import { promptBlocker } from "./hosted-consent.prompt.ts"; +import type { PromptBlocker } from "./hosted-consent.prompt.ts"; +import type { HostedMutationConsent } from "./hosted-consent.token.ts"; +import { ignoredYesFiles, resolveHostedYes } from "./hosted-consent.yes.ts"; + +export interface HostedMutationRequest { + /** An imperative, sentence-case phrase with no trailing period, such as "Apply 3 migrations". */ + readonly action: string; + /** Plan lines printed before the question. */ + readonly details?: ReadonlyArray; + /** Asks for `expected` to be typed instead of a yes/no answer. */ + readonly confirm?: { readonly kind: "typed"; readonly expected: string }; + /** The command has a `--read-only` mode that needs no confirmation. */ + readonly readOnlyAlternative?: boolean; +} + +// The brand is type-only, so minting a token needs this one assertion. +const grant = (action: string, via: HostedMutationConsent["via"]): HostedMutationConsent => + ({ action, via }) as HostedMutationConsent; + +/** The first announced target's banner text, or `None` before any announcement. */ +export const announcedTarget = Effect.serviceOption(CommandTarget).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (cell) => cell.text, + }), + ), +); + +const consentRequired = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + target: Option.Option, + blocker: PromptBlocker, +) { + const ignored = yield* ignoredYesFiles; + const suggestions = [ + blocker.agent + ? "This is an agent run: ask the user before re-running with --yes." + : "Re-run with --yes to confirm.", + ...(request.readOnlyAlternative === true + ? ["For a read-only statement, pass --read-only instead."] + : []), + ...(ignored.length > 0 + ? [`SUPABASE_YES in ${ignored.join(", ")} does not confirm hosted changes; pass --yes.`] + : []), + ]; + const where = Option.match(target, { onNone: () => "", onSome: (text) => ` (target: ${text})` }); + return yield* new HostedMutationConsentRequiredError({ + message: `Confirmation required: ${request.action}${where}. This run can't ask because ${blocker.reason}. Nothing was changed.`, + suggestion: suggestions.join(" "), + }); +}); + +const ask = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + defaultValue: boolean, + onTarget: string, +) { + const output = yield* Output; + if (request.details !== undefined && request.details.length > 0) { + yield* output.raw(`${request.details.join("\n")}\n`, "stderr"); + } + if (request.confirm === undefined) { + return yield* output.promptConfirm(`${request.action}${onTarget}?`, { defaultValue }); + } + yield* output.raw(`${request.action}${onTarget}\n`, "stderr"); + const typed = yield* output.promptText(`Type ${request.confirm.expected} to confirm`); + return typed.trim() === request.confirm.expected; +}); + +/** + * Grants consent for a hosted mutation or fails. `--yes` or a shell `SUPABASE_YES` (never a dotenv + * file) grants it; otherwise only an interactive text-mode terminal with no agent driving it is + * asked. Stdin is never read, so a piped answer does not count. + */ +export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(function* ( + request: HostedMutationRequest, +) { + const policy = yield* TargetPolicy; + if (policy.consent === "none") { + return yield* Effect.die( + new Error( + `requireHostedMutationConsent needs a target policy with consent "yes" or "no"; "${policy.group}" declares "none"`, + ), + ); + } + const output = yield* Output; + const target = yield* announcedTarget; + const onTarget = Option.match(target, { onNone: () => "", onSome: (text) => ` on ${text}` }); + + const approvedBy = yield* resolveHostedYes; + if (Option.isSome(approvedBy)) { + yield* output.raw(`Confirmed by ${approvedBy.value}: ${request.action}${onTarget}\n`, "stderr"); + yield* Effect.annotateCurrentSpan("consent.via", "yes"); + return grant(request.action, "yes"); + } + + const blocker = yield* promptBlocker; + if (Option.isSome(blocker)) return yield* consentRequired(request, target, blocker.value); + + const confirmed = yield* ask(request, policy.consent === "yes", onTarget).pipe( + Effect.catchTag("NonInteractiveError", () => + consentRequired(request, target, { reason: "no prompt is available", agent: false }), + ), + ); + if (!confirmed) { + return yield* new HostedMutationDeclinedError({ message: CONTEXT_CANCELED_MESSAGE }); + } + yield* Effect.annotateCurrentSpan("consent.via", "prompt"); + return grant(request.action, "prompt"); +}); + +/** `None` for a local target or connection; otherwise {@link requireHostedMutationConsent}. */ +export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForTarget")(function* ( + target: ResolvedTarget | ResolvedDbConfig, + request: HostedMutationRequest, +) { + const isLocal = "isLocal" in target ? target.isLocal : target.kind === "local"; + if (isLocal) return Option.none(); + return Option.some(yield* requireHostedMutationConsent(request)); +}); + +/** + * `Some` only under `--yes` or a shell `SUPABASE_YES`; never prompts, so a read can hand it to a + * connection that may clear a network ban. + */ +export const hostedMutationPreapproval = Effect.map(resolveHostedYes, (approvedBy) => + Option.isSome(approvedBy) + ? Option.some(grant("Remove network bans", "yes")) + : Option.none(), +); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts new file mode 100644 index 0000000000..2926bfe3c2 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -0,0 +1,672 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; + +import { + mockCommandSettings, + withConfigEnv, + withEmptyConfigEnv, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { + announceTarget, + type CommandTargetJson, +} from "../../shared/output/command-target.service.ts"; +import type { OutputFormat } from "../../shared/output/types.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import { AgentFlag, OutputFlag, YesFlag } from "../global-flags.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import type { TargetPolicyShape } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import { + hostedMutationPreapproval, + requireHostedMutationConsent, + requireTargetMutationConsent, +} from "./hosted-consent.ts"; +import type { HostedMutationRequest } from "./hosted-consent.ts"; + +type OutputFlagValue = "pretty" | "json" | "yaml" | "toml" | "env" | "table" | "csv"; + +interface Scenario { + readonly policy?: TargetPolicyShape; + readonly format?: OutputFormat; + readonly interactive?: boolean; + readonly stdinIsTty?: boolean; + readonly yes?: boolean; + readonly args?: ReadonlyArray; + readonly outputFlag?: OutputFlagValue; + readonly confirmResponses?: ReadonlyArray; + readonly textResponses?: ReadonlyArray; + readonly promptFails?: boolean; + readonly shellYes?: string; + readonly env?: Readonly>; + readonly announced?: string; + readonly agent?: "auto" | "yes" | "no"; + readonly aiTool?: string; + readonly cwd?: string; + readonly workdir?: string; + readonly withoutFilesystem?: boolean; +} + +const REQUEST: HostedMutationRequest = { + action: "Apply 3 migrations", + details: [" 20240101_a.sql", " 20240102_b.sql"], +}; + +const UNVERIFIED_JSON: CommandTargetJson = { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + environment: "unverified", +}; + +const interactiveTerminal: Scenario = { format: "text", interactive: true, stdinIsTty: true }; + +const harness = (scenario: Scenario) => { + const out = mockOutput({ + format: scenario.format ?? "text", + interactive: scenario.interactive, + promptConfirmResponses: scenario.confirmResponses, + promptTextResponses: scenario.textResponses, + promptConfirmFail: scenario.promptFails, + }); + const layer = Layer.mergeAll( + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + mockTty({ + stdinIsTty: scenario.stdinIsTty ?? false, + stdoutIsTty: scenario.stdinIsTty ?? false, + }), + Layer.succeed(YesFlag, scenario.yes ?? false), + Layer.succeed(CliArgs, { args: scenario.args ?? [] }), + Layer.succeed( + OutputFlag, + scenario.outputFlag === undefined ? Option.none() : Option.some(scenario.outputFlag), + ), + Layer.succeed(TargetPolicy, scenario.policy ?? { group: "hosted", consent: "no" }), + scenario.agent === undefined ? Layer.empty : Layer.succeed(AgentFlag, scenario.agent), + scenario.aiTool === undefined + ? Layer.empty + : Layer.succeed(AiTool, { name: Option.some(scenario.aiTool) }), + scenario.withoutFilesystem === true + ? Layer.empty + : Layer.merge(BunServices.layer, mockRuntimeInfo({ cwd: scenario.cwd ?? "/test/project" })), + scenario.workdir === undefined + ? Layer.empty + : mockCommandSettings({ workdir: scenario.workdir }), + ); + const shellEnv = { + NODE_ENV: "", + ...(scenario.shellYes === undefined ? {} : { SUPABASE_YES: scenario.shellYes }), + ...scenario.env, + }; + const withShellEnv = (body: Effect.Effect) => + withEmptyConfigEnv(withConfigEnv(shellEnv, body)); + const prepare = (body: Effect.Effect) => + withShellEnv( + (scenario.announced === undefined + ? body + : Effect.flatMap( + announceTarget({ text: scenario.announced, json: UNVERIFIED_JSON }), + () => body, + ) + ).pipe(Effect.provide(layer)), + ); + return { out, prepare }; +}; + +const consent = (scenario: Scenario, request: HostedMutationRequest = REQUEST) => { + const { out, prepare } = harness(scenario); + const exit = prepare(requireHostedMutationConsent(request)).pipe(Effect.exit); + return { out, exit }; +}; + +const successOf = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); + return exit.value; +}; + +const failureOf = (exit: Exit.Exit): E => { + if (!Exit.isFailure(exit)) throw new Error("expected a failure"); + const failure = Exit.findErrorOption(exit); + if (Option.isNone(failure)) + throw new Error(`expected a typed failure, got ${Cause.pretty(exit.cause)}`); + return failure.value; +}; + +describe("requireHostedMutationConsent", () => { + describe("default answer", () => { + it.effect.each([ + { consent: "no", expected: false }, + { consent: "yes", expected: true }, + ] as const)( + "offers $expected as the default when the policy says $consent", + ({ consent: policyConsent, expected }) => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: policyConsent }, + confirmResponses: [true], + }); + yield* run.exit; + expect(run.out.promptConfirmCalls).toEqual([ + { message: "Apply 3 migrations?", opts: { defaultValue: expected } }, + ]); + }), + ); + + it.effect("dies when the command declares no consent", () => + Effect.gen(function* () { + const exit = yield* consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: "none" }, + }).exit; + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + }), + ); + }); + + describe("--yes", () => { + it.effect("grants without asking and echoes the action and target on stderr", () => + Effect.gen(function* () { + const run = consent({ + yes: true, + format: "json", + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "yes" }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toContain( + "Confirmed by --yes: Apply 3 migrations on project abcdefghijklmnopqrst\n", + ); + }), + ); + + it.effect("omits the target when nothing was announced", () => + Effect.gen(function* () { + const run = consent({ yes: true }); + yield* run.exit; + expect(run.out.stderrText).toBe("Confirmed by --yes: Apply 3 migrations\n"); + }), + ); + + it.effect("skips a typed confirmation", () => + Effect.gen(function* () { + const run = consent( + { ...interactiveTerminal, yes: true }, + { action: "delete compute api", confirm: { kind: "typed", expected: "api" } }, + ); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("is overridden by an explicit --yes=false even when SUPABASE_YES is set", () => + Effect.gen(function* () { + const exit = yield* consent({ args: ["--yes=false"], shellYes: "1" }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + }); + + describe("SUPABASE_YES", () => { + const withDotenvDir = ( + files: Readonly>, + body: (dir: string) => Effect.Effect, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + for (const [name, contents] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(dir, name)), { recursive: true }); + yield* fs.writeFileString(path.join(dir, name), contents); + } + return yield* body(dir); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + + it.effect( + "grants when set in the shell environment and echoes SUPABASE_YES as the source", + () => + Effect.gen(function* () { + const run = consent({ shellYes: "1", format: "json" }); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by SUPABASE_YES: Apply 3 migrations"); + }), + ); + + it.effect("does not grant when it is only in the project .env", () => + withDotenvDir({ "supabase/.env": "SUPABASE_YES=1\n" }, (dir) => + Effect.gen(function* () { + const exit = yield* consent({ cwd: dir, workdir: dir }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect.each([".env", ".env.local", ".env.development", ".env.development.local"])( + "does not grant a shell value that matches %s in the working directory", + (name) => + withDotenvDir({ [name]: "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("checks the dotenv files NODE_ENV selects", () => + withDotenvDir({ ".env.production": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const production = yield* consent({ + shellYes: "1", + cwd, + env: { NODE_ENV: "production" }, + }).exit; + expect(failureOf(production)).toBeInstanceOf(HostedMutationConsentRequiredError); + const development = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(development).via).toBe("yes"); + }), + ), + ); + + it.effect.each([ + { name: "a different value", contents: "SUPABASE_YES=true\n" }, + { name: "an inline comment Bun strips", contents: "SUPABASE_YES=1#x\n" }, + { name: "an empty value", contents: "SUPABASE_YES=\n" }, + ])( + "does not grant a shell value when the working directory's .env sets it with $name", + ({ contents }) => + withDotenvDir({ ".env": contents }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + const error = failureOf(exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + suggestion: expect.stringContaining( + "SUPABASE_YES in .env does not confirm hosted changes", + ), + }); + }), + ), + ); + + it.effect("grants when the working directory's dotenv files do not mention it", () => + withDotenvDir({ ".env": "OTHER=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(exit).via).toBe("yes"); + }), + ), + ); + + it.effect("does not grant when a dotenv file that does not parse mentions it", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n!!!\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("does not grant when the filesystem cannot be checked", () => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", withoutFilesystem: true }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + + it.effect("still grants --yes when a working-directory .env sets the variable", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const run = consent({ yes: true, cwd }); + expect(successOf(yield* run.exit).via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by --yes: Apply 3 migrations"); + }), + ), + ); + }); + + describe("when the run cannot prompt", () => { + it.effect.each([ + { + name: "json output", + scenario: { format: "json", stdinIsTty: true }, + reason: "its output format is json", + }, + { + name: "stream-json output", + scenario: { format: "stream-json", stdinIsTty: true }, + reason: "its output format is stream-json", + }, + { + name: "-o yaml in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "yaml" }, + reason: "it writes machine-readable -o yaml output", + }, + { + name: "-o json in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "json" }, + reason: "it writes machine-readable -o json output", + }, + { + name: "piped stdin carrying an affirmative answer", + scenario: { + format: "text", + interactive: true, + stdinIsTty: false, + confirmResponses: [true], + }, + reason: "stdin is not a terminal", + }, + { + name: "a non-interactive stdout", + scenario: { format: "text", interactive: false, stdinIsTty: true }, + reason: "stdout is not a terminal", + }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario; reason: string }>)( + "fails with ConsentRequiredError for $name and never asks", + ({ scenario, reason }) => + Effect.gen(function* () { + const run = consent({ ...scenario, announced: "project abcdefghijklmnopqrst" }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: `Confirmation required: Apply 3 migrations (target: project abcdefghijklmnopqrst). This run can't ask because ${reason}. Nothing was changed.`, + suggestion: "Re-run with --yes to confirm.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("leaves the target out of the message when nothing was announced", () => + Effect.gen(function* () { + const error = failureOf(yield* consent({ format: "json" }).exit); + expect(error).toMatchObject({ + message: + "Confirmation required: Apply 3 migrations. This run can't ask because its output format is json. Nothing was changed.", + }); + }), + ); + + it.effect("fails with ConsentRequiredError when the prompt turns out to be unavailable", () => + Effect.gen(function* () { + const error = failureOf(yield* consent({ ...interactiveTerminal, promptFails: true }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because no prompt is available"), + }); + }), + ); + + it.effect("suggests --read-only when the command has a read-only mode", () => + Effect.gen(function* () { + const error = failureOf( + yield* consent({ format: "json" }, { ...REQUEST, readOnlyAlternative: true }).exit, + ); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. For a read-only statement, pass --read-only instead.", + }); + }), + ); + + it.effect("says a project .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", cwd: dir, workdir: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in supabase/.env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("says a working-directory .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(dir, ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", shellYes: "1", cwd: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in .env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); + + describe("agents", () => { + it.effect.each([ + { name: "--agent yes", scenario: { agent: "yes" } }, + { name: "a detected agent", scenario: { agent: "auto", aiTool: "claude_code" } }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario }>)( + "refuses to prompt for $name in an interactive terminal", + ({ scenario }) => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, ...scenario, confirmResponses: [true] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because it is running in agent mode"), + suggestion: "This is an agent run: ask the user before re-running with --yes.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("prompts when --agent no overrides a detected agent", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + agent: "no", + aiTool: "claude_code", + confirmResponses: [true], + }); + expect(successOf(yield* run.exit).via).toBe("prompt"); + }), + ); + + it.effect("grants --yes to an agent", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, yes: true, agent: "yes" }); + expect(successOf(yield* run.exit).via).toBe("yes"); + }), + ); + }); + + describe("in an interactive terminal", () => { + it.effect("grants an accepted prompt and prints the details first", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + confirmResponses: [true], + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "prompt" }); + expect(run.out.stderrText).toBe( + "Target: project abcdefghijklmnopqrst\n 20240101_a.sql\n 20240102_b.sql\n", + ); + expect(run.out.promptConfirmCalls.map((call) => call.message)).toEqual([ + "Apply 3 migrations on project abcdefghijklmnopqrst?", + ]); + }), + ); + + it.effect("fails with DeclinedError when the answer is no", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, confirmResponses: [false] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(error).toMatchObject({ message: "context canceled" }); + }), + ); + + describe("typed confirmation", () => { + const typed: HostedMutationRequest = { + action: "delete compute api", + details: ["This permanently deletes the compute."], + confirm: { kind: "typed", expected: "api" }, + }; + + it.effect("grants when the typed answer matches", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: [" api "] }, typed); + const token = successOf(yield* run.exit); + expect(token.via).toBe("prompt"); + expect(run.out.promptTextCalls.map((call) => call.message)).toEqual([ + "Type api to confirm", + ]); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe( + "This permanently deletes the compute.\ndelete compute api\n", + ); + }), + ); + + it.effect("fails with DeclinedError when the typed answer differs", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: ["apis"] }, typed); + expect(failureOf(yield* run.exit)).toBeInstanceOf(HostedMutationDeclinedError); + }), + ); + }); + }); + + it("classifies the errors for telemetry", () => { + expect( + classifyCliErrorActionability( + new HostedMutationConsentRequiredError({ message: "m", suggestion: "s" }), + ), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "consent_required", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect( + classifyCliErrorActionability(new HostedMutationDeclinedError({ message: "m" })), + ).toMatchObject({ error_kind: "user_cancelled", error_category: "cancelled" }); + }); +}); + +describe("hostedMutationPreapproval", () => { + const preapproval = (scenario: Scenario) => harness(scenario).prepare(hostedMutationPreapproval); + + it.effect("is Some under --yes", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ yes: true }))).toBe(true); + }), + ); + + it.effect("is Some under a shell SUPABASE_YES", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ shellYes: "1" }))).toBe(true); + }), + ); + + it.effect("is None in a terminal that could prompt", () => + Effect.gen(function* () { + const { out, prepare } = harness({ ...interactiveTerminal, confirmResponses: [true] }); + expect(Option.isNone(yield* prepare(hostedMutationPreapproval))).toBe(true); + expect(out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("is None under --yes=false", () => + Effect.gen(function* () { + expect(Option.isNone(yield* preapproval({ args: ["--yes=false"], shellYes: "1" }))).toBe( + true, + ); + }), + ); + + it.effect("is None for a shell value that a working-directory .env also sets", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(cwd, ".env.local"), "SUPABASE_YES=1\n"); + expect(Option.isNone(yield* preapproval({ shellYes: "1", cwd }))).toBe(true); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); + +describe("requireTargetMutationConsent", () => { + const dbConfig = (isLocal: boolean): ResolvedDbConfig => ({ + conn: { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "", + database: "postgres", + }, + isLocal, + }); + const linked: ResolvedTarget = { + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "file", + matchesLinkedRef: true, + }; + + const forTarget = (target: ResolvedTarget | ResolvedDbConfig, scenario: Scenario = {}) => { + const { out, prepare } = harness(scenario); + return { out, exit: prepare(requireTargetMutationConsent(target, REQUEST)).pipe(Effect.exit) }; + }; + + it.effect.each([ + { name: "a local target", target: { kind: "local" } satisfies ResolvedTarget }, + { name: "a connection to the local stack", target: dbConfig(true) }, + ])("is None for $name without asking or echoing", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, yes: true }); + const result = successOf(yield* run.exit); + expect(Option.isNone(result)).toBe(true); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe(""); + }), + ); + + it.effect.each([ + { name: "a linked target", target: linked }, + { + name: "an unclassified --db-url target", + target: { + kind: "db-url", + url: Redacted.make("postgresql://postgres:secret@db.example.com:5432/postgres"), + } satisfies ResolvedTarget, + }, + { name: "a connection to a hosted database", target: dbConfig(false) }, + ])("asks for $name", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, confirmResponses: [true] }); + const result = successOf(yield* run.exit); + expect(Option.map(result, (token) => token.via)).toEqual(Option.some("prompt")); + }), + ); + + it.effect("fails for a hosted target in a run that cannot prompt", () => + Effect.gen(function* () { + const error = failureOf(yield* forTarget(linked, { format: "json" }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); +}); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts new file mode 100644 index 0000000000..3bff9f513f --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -0,0 +1,98 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { candidateDotenvFilenames } from "../../shared/config/cli-config-env.ts"; +import { parseDotEnv } from "../../shared/config/dotenv.ts"; +import { envValue } from "../../shared/config/env-option.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { resolveYesSource } from "../global-flags.ts"; +import { parseEnvBool } from "../supabase-env.ts"; + +export type HostedYesSource = "--yes" | "SUPABASE_YES"; + +const YES_VARIABLE = "SUPABASE_YES"; + +interface DotenvDefinition { + readonly file: string; + /** `None` when the file does not parse but mentions the variable, which counts as any value. */ + readonly value: Option.Option; +} + +interface Platform { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly cwd: string; +} + +// Without a filesystem the origin of `SUPABASE_YES` cannot be checked, so callers treat it as +// unverified. +const platform = Effect.gen(function* () { + const fs = yield* Effect.serviceOption(FileSystem.FileSystem); + const path = yield* Effect.serviceOption(Path.Path); + const runtime = yield* Effect.serviceOption(RuntimeInfo); + return Option.all({ fs, path, runtime }).pipe( + Option.map(({ fs, path, runtime }): Platform => ({ fs, path, cwd: runtime.cwd })), + ); +}); + +const envNameFrom = (variable: string) => + Effect.map(envValue(variable), (value) => + value === undefined || value === "" ? "development" : value, + ); + +const definitionsIn = Effect.fnUntraced(function* (host: Platform, dir: string, env: string) { + const found: Array = []; + for (const name of candidateDotenvFilenames(env)) { + const file = host.path.join(dir, name); + const content = yield* host.fs.readFileString(file).pipe(Effect.option); + if (Option.isNone(content)) continue; + const parsed = yield* Effect.try(() => parseDotEnv(content.value)).pipe(Effect.option); + if (Option.isNone(parsed)) { + if (content.value.includes(YES_VARIABLE)) found.push({ file, value: Option.none() }); + } else if (Object.hasOwn(parsed.value, YES_VARIABLE)) { + found.push({ file, value: Option.some(parsed.value[YES_VARIABLE]!) }); + } + } + return found; +}); + +/** The files Bun loads into `process.env` from the working directory before any CLI code runs. */ +const autoloadedDefinitions = (host: Platform) => + Effect.flatMap(envNameFrom("NODE_ENV"), (env) => definitionsIn(host, host.cwd, env)); + +/** + * Confirms a hosted change only for `--yes` or a `SUPABASE_YES` the shell exported. Bun copies + * working-directory dotenv files into the environment and its parsing can differ from ours, so a + * working-directory dotenv file that defines `SUPABASE_YES` at all, whatever its value, makes the + * environment's value the file's and not consent. + */ +export const resolveHostedYes = Effect.gen(function* () { + const source = yield* resolveYesSource; + if (Option.isNone(source)) return Option.none(); + if (source.value.via === "flag") return Option.some("--yes"); + const host = yield* platform; + if (Option.isNone(host)) return Option.none(); + const fromFile = (yield* autoloadedDefinitions(host.value)).length > 0; + return fromFile ? Option.none() : Option.some("SUPABASE_YES"); +}); + +/** Dotenv files, relative to the cwd, whose `SUPABASE_YES` does not confirm a hosted change. */ +export const ignoredYesFiles = Effect.gen(function* () { + const host = yield* platform; + if (Option.isNone(host)) return []; + const { path, cwd } = host.value; + const workdir = Option.match(yield* Effect.serviceOption(CommandSettings), { + onNone: () => cwd, + onSome: (settings) => settings.workdir, + }); + const projectEnv = yield* envNameFrom("SUPABASE_ENV"); + + const projectDefinitions = [ + ...(yield* definitionsIn(host.value, path.join(workdir, "supabase"), projectEnv)), + ...(yield* definitionsIn(host.value, workdir, projectEnv)), + ].filter(({ value }) => Option.isNone(value) || parseEnvBool(value.value)); + const files = [...(yield* autoloadedDefinitions(host.value)), ...projectDefinitions].map( + ({ file }) => path.relative(cwd, file), + ); + return [...new Set(files)]; +}); diff --git a/apps/cli/src/command-internal/hosted-versions.ts b/apps/cli/src/command-internal/hosted-versions.ts index f53a813f91..c14b0ee80c 100644 --- a/apps/cli/src/command-internal/hosted-versions.ts +++ b/apps/cli/src/command-internal/hosted-versions.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect"; import * as HttpClientError from "effect/http/HttpClientError"; import { CommandSettings } from "../config/command-settings.service.ts"; -import type { ProjectLink } from "./project-link.ts"; +import type { ProjectLink } from "../shared/config/project-link.ts"; import type { HostedProbe } from "./stack-lock.ts"; import { extractServiceKeys } from "./tenant-keys.ts"; import { probeFailure, probeTenantVersions, type HostedObservation } from "./tenant-versions.ts"; diff --git a/apps/cli/src/command-internal/hostname.ts b/apps/cli/src/command-internal/hostname.ts index 9d1a7ac23a..857353d9b1 100644 --- a/apps/cli/src/command-internal/hostname.ts +++ b/apps/cli/src/command-internal/hostname.ts @@ -1,30 +1,23 @@ import { Config, Crypto, Effect, FileSystem, Option, Path, Schema } from "effect"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; +import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; const LOCAL_HOST = "127.0.0.1"; const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; const DEFAULT_CONTEXT_NAME = "default"; -type Environment = Readonly>; +// The docker client only sees the shell env, so daemon discovery never reads a project `.env*`. +const shellOption = (name: string): Effect.Effect, Config.ConfigError> => + Config.option(Config.String(name)); -const envOption = ( - name: string, - projectEnvValues?: Environment, -): Effect.Effect, Config.ConfigError> => { - const projectValue = Option.fromNullishOr(projectEnvValues?.[name]); - return Option.isSome(projectValue) - ? Effect.succeed(projectValue) - : Config.option(Config.String(name)); -}; - -const dockerConfigDir = ( - projectEnvValues?: Environment, -): Effect.Effect => +const dockerConfigDir = (): Effect.Effect => Effect.gen(function* () { const runtime = yield* RuntimeInfo; const path = yield* Path.Path; - const configured = yield* envOption("DOCKER_CONFIG", projectEnvValues); + const configured = yield* shellOption("DOCKER_CONFIG"); return Option.getOrElse(configured.pipe(Option.filter((value) => value.length > 0)), () => path.join(runtime.homeDir, ".docker"), ); @@ -51,15 +44,17 @@ const decodeDockerMeta = ( ): Effect.Effect>> => Schema.decodeEffect(Schema.fromJsonString(DockerMetaSchema))(content).pipe(Effect.option); -const currentDockerContextName = ( - projectEnvValues?: Environment, -): Effect.Effect => +const currentDockerContextName = (): Effect.Effect< + string, + Config.ConfigError, + RuntimeInfo | FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { - const fromEnv = yield* envOption("DOCKER_CONTEXT", projectEnvValues); + const fromEnv = yield* shellOption("DOCKER_CONTEXT"); if (Option.isSome(fromEnv) && fromEnv.value.length > 0) return fromEnv.value; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const configDir = yield* dockerConfigDir(projectEnvValues); + const configDir = yield* dockerConfigDir(); const configPath = path.join(configDir, "config.json"); const config = yield* fs.readFileString(configPath).pipe(Effect.option); @@ -73,13 +68,13 @@ const currentDockerContextName = ( return DEFAULT_CONTEXT_NAME; }); -const dockerContextEndpointHost = (contextName: string, projectEnvValues?: Environment) => { +const dockerContextEndpointHost = (contextName: string) => { if (contextName === DEFAULT_CONTEXT_NAME) return Effect.succeed(Option.none()); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const configDir = yield* dockerConfigDir(projectEnvValues); + const configDir = yield* dockerConfigDir(); const digest = yield* crypto .digest("SHA-256", new TextEncoder().encode(contextName)) .pipe(Effect.option); @@ -122,36 +117,35 @@ export function platformDefaultDockerHost(platform: NodeJS.Platform): string { } /** Resolves the daemon endpoint selected by the Docker CLI. */ -export const resolveDockerDaemonEndpoint = ( - projectEnvValues?: Environment, -): Effect.Effect< +export const resolveDockerDaemonEndpoint = Effect.fnUntraced(function* (): Effect.fn.Return< Option.Option, Config.ConfigError, RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto -> => - Effect.gen(function* () { - const runtime = yield* RuntimeInfo; - const dockerHost = yield* envOption("DOCKER_HOST", projectEnvValues); - if (Option.isSome(dockerHost) && dockerHost.value.length > 0) return dockerHost; - const contextName = yield* currentDockerContextName(projectEnvValues); - if (contextName === DEFAULT_CONTEXT_NAME) { - return Option.some(platformDefaultDockerHost(runtime.platform)); - } - return yield* dockerContextEndpointHost(contextName, projectEnvValues); - }); +> { + const runtime = yield* RuntimeInfo; + const dockerHost = yield* shellOption("DOCKER_HOST"); + if (Option.isSome(dockerHost) && dockerHost.value.length > 0) return dockerHost; + const contextName = yield* currentDockerContextName(); + if (contextName === DEFAULT_CONTEXT_NAME) { + return Option.some(platformDefaultDockerHost(runtime.platform)); + } + return yield* dockerContextEndpointHost(contextName); +}); -/** Resolves the hostname used for local Supabase service connections. */ +/** Resolves the hostname for local service connections: `SUPABASE_SERVICES_HOSTNAME` (shell, then project `.env*`), else the shell-selected Docker daemon's tcp host. */ export const getHostname = ( - projectEnvValues?: Environment, + resolvedConfig: Pick, ): Effect.Effect< string, - Config.ConfigError, + Config.ConfigError | CliConfigLoadError, RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto > => Effect.gen(function* () { - const override = yield* envOption("SUPABASE_SERVICES_HOSTNAME", projectEnvValues); - if (Option.isSome(override) && override.value.length > 0) return override.value; - const endpoint = yield* resolveDockerDaemonEndpoint(projectEnvValues); + const { SUPABASE_SERVICES_HOSTNAME: override } = yield* resolvedConfig.envValues([ + "SUPABASE_SERVICES_HOSTNAME", + ]); + if (override !== undefined) return override; + const endpoint = yield* resolveDockerDaemonEndpoint(); if (Option.isSome(endpoint)) { const host = yield* hostFromTcpEndpoint(endpoint.value); if (Option.isSome(host)) return host.value; @@ -160,7 +154,7 @@ export const getHostname = ( }); /** Keeps Bun from proxying the CLI's loopback HTTP requests. */ -export function configureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { +export function configureLoopbackProxyBypass(env: NodeJS.ProcessEnv = ambientEnvironment()): void { const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY"; const current = env[key]; env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY; diff --git a/apps/cli/src/command-internal/hostname.unit.test.ts b/apps/cli/src/command-internal/hostname.unit.test.ts index 712fbdb58b..72d6f8d273 100644 --- a/apps/cli/src/command-internal/hostname.unit.test.ts +++ b/apps/cli/src/command-internal/hostname.unit.test.ts @@ -13,6 +13,7 @@ import { Path, Schema, } from "effect"; +import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { configureLoopbackProxyBypass, @@ -37,6 +38,20 @@ const runtimeLayer = Layer.succeed( type HostnameServices = RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto; +/** A resolved config whose env-tier lookup yields `env`; the real tier order is covered by the config service tests. */ +const resolvedConfigWith = (env: Readonly> = {}) => ({ + envValues: (names: Iterable) => + Effect.succeed( + Object.fromEntries( + [...names].flatMap((name) => { + const value = env[name]; + return value === undefined || value === "" ? [] : [[name, value]]; + }), + ), + ), +}); +const noConfig = resolvedConfigWith(); + function configLayer(env: Readonly>) { return Layer.mergeAll( BunServices.layer, @@ -75,7 +90,7 @@ const writeDockerConfigDir = Effect.fnUntraced(function* (options: { function withDockerConfig( options: Parameters[0], env: Readonly>, - run: () => Effect.Effect, + run: () => Effect.Effect, ) { return writeDockerConfigDir(options).pipe( Effect.provide(BunServices.layer), @@ -86,29 +101,35 @@ function withDockerConfig( } describe("getHostname", () => { - it.effect("prefers a project override and preserves an explicit empty value", () => + it.effect("uses the resolved SUPABASE_SERVICES_HOSTNAME and ignores an empty one", () => Effect.gen(function* () { - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "db.internal" })).toBe("db.internal"); - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "" })).toBe("127.0.0.1"); + expect( + yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "db.internal" })), + ).toBe("db.internal"); + expect(yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "" }))).toBe( + "127.0.0.1", + ); }).pipe(Effect.provide(configLayer({}))), ); - it.effect("reads ambient DOCKER_HOST and extracts IPv4 and IPv6 hosts", () => + it.effect("prefers the resolved hostname over a tcp DOCKER_HOST", () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("docker-host"); - expect(yield* getHostname({})).toBe("docker-host"); - expect(yield* getHostname({ DOCKER_HOST: "tcp://[::1]:2375" })).toBe("::1"); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeLayer, - ConfigProvider.layer( - ConfigProvider.fromEnvRecord({ DOCKER_HOST: "tcp://docker-host:2375" }), - ), - ), - ), - ), + expect( + yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "override" })), + ).toBe("override"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), + ); + + it.effect("extracts the host from an ambient tcp DOCKER_HOST", () => + Effect.gen(function* () { + expect(yield* getHostname(noConfig)).toBe("docker-host"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), + ); + + it.effect("extracts an IPv6 host from an ambient tcp DOCKER_HOST", () => + Effect.gen(function* () { + expect(yield* getHostname(noConfig)).toBe("::1"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://[::1]:2375" }))), ); it.effect("uses currentContext from Docker config", () => @@ -117,7 +138,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("remote-host"); + expect(yield* getHostname(noConfig)).toBe("remote-host"); }), ), ); @@ -131,7 +152,7 @@ describe("getHostname", () => { { DOCKER_CONTEXT: "envctx" }, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("envctx-host"); + expect(yield* getHostname(noConfig)).toBe("envctx-host"); }), ), ); @@ -142,7 +163,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("::1"); + expect(yield* getHostname(noConfig)).toBe("::1"); }), ), ); @@ -153,7 +174,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -162,7 +183,7 @@ describe("getHostname", () => { withDockerConfig({ currentContext: "ghost" }, {}, () => Effect.gen(function* () { expect(yield* resolveDockerDaemonEndpoint()).toEqual(Option.none()); - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -170,7 +191,7 @@ describe("getHostname", () => { it.effect("falls back to the default context when Docker config is missing", () => withDockerConfig({}, {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -184,7 +205,7 @@ describe("getHostname", () => { expect(yield* resolveDockerDaemonEndpoint()).toEqual( Option.some("unix:///var/run/docker.sock"), ); - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -195,7 +216,7 @@ describe("getHostname", () => { { DOCKER_HOST: "tcp://direct-host:2375" }, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("direct-host"); + expect(yield* getHostname(noConfig)).toBe("direct-host"); }), ), ); @@ -215,7 +236,7 @@ describe("getHostname", () => { it.effect("returns loopback for an absent or non-tcp endpoint", () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }).pipe( Effect.provide( Layer.mergeAll( @@ -233,13 +254,16 @@ describe("getHostname", () => { describe("resolveDockerDaemonEndpoint", () => { it.effect("returns DOCKER_HOST verbatim, including non-tcp schemes", () => Effect.gen(function* () { - expect( - yield* resolveDockerDaemonEndpoint({ DOCKER_HOST: "unix:///custom/engine.sock" }), - ).toEqual(Option.some("unix:///custom/engine.sock")); - expect(yield* resolveDockerDaemonEndpoint({ DOCKER_HOST: "tcp://docker-host:2375" })).toEqual( - Option.some("tcp://docker-host:2375"), + expect(yield* resolveDockerDaemonEndpoint()).toEqual( + Option.some("unix:///custom/engine.sock"), ); - }).pipe(Effect.provide(configLayer({}))), + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "unix:///custom/engine.sock" }))), + ); + + it.effect("returns a tcp DOCKER_HOST verbatim", () => + Effect.gen(function* () { + expect(yield* resolveDockerDaemonEndpoint()).toEqual(Option.some("tcp://docker-host:2375")); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), ); it.effect("maps the default context to the platform default daemon endpoint", () => diff --git a/apps/cli/src/command-internal/go-json.ts b/apps/cli/src/command-internal/html-safe-json.ts similarity index 65% rename from apps/cli/src/command-internal/go-json.ts rename to apps/cli/src/command-internal/html-safe-json.ts index 1d9d8a3b89..39652e6361 100644 --- a/apps/cli/src/command-internal/go-json.ts +++ b/apps/cli/src/command-internal/html-safe-json.ts @@ -1,13 +1,12 @@ /** - * Byte-faithful reproduction of `encoding/json`'s encoder, for commands that must match Go's - * stdout exactly. + * Byte-stable JSON encoder for commands whose stdout format is fixed. * * Doesn't sort object keys — the caller builds objects whose key insertion order is already the * desired order; a `Map` is preserved as true insertion order (a plain object reorders * integer-like string keys into ascending numeric order, which would undo a lexicographic sort). * * Differs from `JSON.stringify(x, null, 2)` in escaping `<`, `>`, `&`, and control characters - * (`\u0008`/`\u000c` instead of `\b`/`\f`) the way Go's default encoder does. + * (`\u0008`/`\u000c` instead of `\b`/`\f`). */ const HEX = "0123456789abcdef"; @@ -17,11 +16,11 @@ function unicodeEscape(codeUnit: number): string { } /** - * Quotes and escapes a string exactly as `encoding/json` does with default HTML escaping. - * Iterates by UTF-16 code unit; the only non-ASCII runes Go escapes (U+2028, U+2029) are single + * Quotes and escapes a string with HTML escaping. + * Iterates by UTF-16 code unit; the only non-ASCII characters escaped (U+2028, U+2029) are single * BMP code units, so code units suffice. */ -export function escapeGoJsonString(value: string): string { +export function escapeHtmlSafeJsonString(value: string): string { let out = '"'; for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i); @@ -65,10 +64,10 @@ function walk(value: unknown, depth: number, pretty: boolean): string { if (value === null || value === undefined) return "null"; switch (typeof value) { case "string": - return escapeGoJsonString(value); + return escapeHtmlSafeJsonString(value); case "number": - // JSON.stringify collapses `-0` to `"0"`, but `encoding/json` marshals a float64 negative - // zero as `-0` — reachable via `gen bearer-jwt`'s signed payload, where the signed bytes + // JSON.stringify collapses `-0` to `"0"`, but a float64 negative + // zero marshals as `-0` — reachable via `gen bearer-jwt`'s signed payload, where the signed bytes // must match. if (!Number.isFinite(value)) return "null"; return Object.is(value, -0) ? "-0" : JSON.stringify(value); @@ -86,7 +85,7 @@ function walk(value: unknown, depth: number, pretty: boolean): string { return `[${open}${items.join(separator)}${close}${closeIndent}]`; } // A plain object reorders integer-like string keys ("2", "10") into ascending numeric order on - // enumeration, unlike a real Go map's lexicographic order; callers needing that order (e.g. + // enumeration, unlike lexicographic order; callers needing that order (e.g. // `sortKeysDeep`) pass a `Map`, whose iteration order is true insertion order. const entries = value instanceof Map @@ -95,34 +94,34 @@ function walk(value: unknown, depth: number, pretty: boolean): string { if (entries.length === 0) return "{}"; const colon = pretty ? ": " : ":"; const lines = entries.map( - ([key, val]) => `${indent}${escapeGoJsonString(key)}${colon}${walk(val, depth + 1, pretty)}`, + ([key, val]) => + `${indent}${escapeHtmlSafeJsonString(key)}${colon}${walk(val, depth + 1, pretty)}`, ); return `{${open}${lines.join(separator)}${close}${closeIndent}}`; } /** - * Encodes a value the way `json.Encoder` with `SetIndent("", " ")` + - * `Encode` does: 2-space indentation, object keys in insertion (struct) order, - * Go string escaping, and a trailing newline. + * Encodes a value with 2-space indentation, object keys in insertion order, + * HTML-safe string escaping, and a trailing newline. */ -export function encodeGoJsonIndented(value: unknown): string { +export function encodeHtmlSafeJsonIndented(value: unknown): string { return walk(value, 0, true) + "\n"; } /** - * Encodes a value the way `json.Marshal` does: compact separators - * (`{"k":v}`), object keys in insertion (struct) order, Go string escaping - * (HTML characters included), and no trailing newline. + * Encodes a value with compact separators + * (`{"k":v}`), object keys in insertion order, HTML-safe string escaping, + * and no trailing newline. */ -export function encodeGoJsonCompact(value: unknown): string { +export function encodeHtmlSafeJsonCompact(value: unknown): string { return walk(value, 0, false); } /** - * `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` rejects. Used to - * reproduce Go's exact `"json: cannot unmarshal into Go value of type "` message. + * Type names for the JSON-representable kinds a decode rejects, used in "expected X, got " + * decode errors. */ -export function goJsonKindName(value: unknown): string { +export function jsonKindName(value: unknown): string { if (Array.isArray(value)) return "array"; switch (typeof value) { case "number": @@ -130,7 +129,7 @@ export function goJsonKindName(value: unknown): string { case "string": return "string"; case "boolean": - return "bool"; + return "boolean"; default: return "value"; } diff --git a/apps/cli/src/command-internal/html-safe-json.unit.test.ts b/apps/cli/src/command-internal/html-safe-json.unit.test.ts new file mode 100644 index 0000000000..733067c7c8 --- /dev/null +++ b/apps/cli/src/command-internal/html-safe-json.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + encodeHtmlSafeJsonCompact, + encodeHtmlSafeJsonIndented, + escapeHtmlSafeJsonString, + jsonKindName, +} from "./html-safe-json.ts"; + +describe("escapeHtmlSafeJsonString", () => { + it("escapes quotes and backslashes", () => { + expect(escapeHtmlSafeJsonString(`a"b\\c`)).toBe('"a\\"b\\\\c"'); + }); + + it("HTML-escapes <, > and &", () => { + expect(escapeHtmlSafeJsonString(" & ")).toBe('"\\u003ca\\u003e \\u0026 \\u003cb\\u003e"'); + }); + + it("uses short escapes for tab/newline/carriage-return", () => { + expect(escapeHtmlSafeJsonString("a\tb\nc\rd")).toBe('"a\\tb\\nc\\rd"'); + }); + + it("uses \\u00xx for other control characters (no \\b / \\f shorthand)", () => { + expect(escapeHtmlSafeJsonString("\b\f")).toBe('"\\u0008\\u000c"'); + }); + + it("escapes U+2028 and U+2029", () => { + expect(escapeHtmlSafeJsonString("

")).toBe('"\\u2028\\u2029"'); + }); +}); + +describe("encodeHtmlSafeJsonIndented", () => { + it("preserves object key insertion order (not alphabetical)", () => { + expect(encodeHtmlSafeJsonIndented({ level: "error", message: "boom" })).toBe( + `{\n "level": "error",\n "message": "boom"\n}\n`, + ); + }); + + it("renders nested arrays of objects with 2-space indent and a trailing newline", () => { + const value = [{ function: "public.f1", issues: [{ level: "error", message: "test 1b" }] }]; + expect(encodeHtmlSafeJsonIndented(value)).toBe( + [ + "[", + " {", + ' "function": "public.f1",', + ' "issues": [', + " {", + ' "level": "error",', + ' "message": "test 1b"', + " }", + " ]", + " }", + "]", + "", + ].join("\n"), + ); + }); + + it("renders empty arrays and objects compactly", () => { + expect(encodeHtmlSafeJsonIndented([])).toBe("[]\n"); + expect(encodeHtmlSafeJsonIndented({})).toBe("{}\n"); + expect(encodeHtmlSafeJsonIndented({ issues: [] })).toBe(`{\n "issues": []\n}\n`); + }); +}); + +describe("encodeHtmlSafeJsonCompact", () => { + it("emits the compact shape with HTML escaping", () => { + expect(encodeHtmlSafeJsonCompact({ metadata_xml: "&stuff", type: "saml" })).toBe( + '{"metadata_xml":"\\u003cxml\\u003e\\u0026stuff\\u003c/xml\\u003e","type":"saml"}', + ); + }); + + it("keeps insertion order, compact separators, and no trailing newline", () => { + expect(encodeHtmlSafeJsonCompact({ b: [1, 2], a: { c: true } })).toBe( + '{"b":[1,2],"a":{"c":true}}', + ); + expect(encodeHtmlSafeJsonCompact([])).toBe("[]"); + expect(encodeHtmlSafeJsonCompact(null)).toBe("null"); + }); + + it("preserves negative zero's sign, unlike plain JSON.stringify", () => { + expect(encodeHtmlSafeJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); + expect(encodeHtmlSafeJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); + expect(encodeHtmlSafeJsonCompact({ extra: 0 })).toBe('{"extra":0}'); + }); + + it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { + const map = new Map([ + ["10", "a"], + ["2", "b"], + ]); + expect(encodeHtmlSafeJsonCompact(map)).toBe('{"10":"a","2":"b"}'); + }); +}); + +describe("jsonKindName", () => { + it("names every JSON-representable kind, including the generic fallback", () => { + expect(jsonKindName([])).toBe("array"); + expect(jsonKindName(1)).toBe("number"); + expect(jsonKindName("s")).toBe("string"); + expect(jsonKindName(true)).toBe("boolean"); + expect(jsonKindName(undefined)).toBe("value"); + }); +}); diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index eb3f93a9c5..136945f5a8 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -3,6 +3,8 @@ import { Effect } from "effect"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClientError from "effect/http/HttpClientError"; +import type { PlanGateEntitlement } from "../shared/output/normalize-error.ts"; + // HttpClientError reasons that indicate the server returned an actual response (vs a transport // failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps // to a `NetworkError`. @@ -95,8 +97,44 @@ export type StatusErrorFactory = new (args: { readonly status: number; readonly body: string; readonly message: string; + readonly entitlement?: PlanGateEntitlement; }) => E; +const FEATURE_KEY_PATTERN = /^[\w.-]{1,128}$/; +const MAX_UPGRADE_URL_LEN = 2048; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +// The parsed `href` percent-encodes control and bidi characters, so the URL is safe to print. +function normalizeUpgradeUrl(raw: string): string | undefined { + if (raw.length > MAX_UPGRADE_URL_LEN || !URL.canParse(raw)) return undefined; + const url = new URL(raw); + const isWebUrl = url.protocol === "https:" || url.protocol === "http:"; + const hasCredentials = url.username !== "" || url.password !== ""; + return isWebUrl && !hasCredentials ? url.href : undefined; +} + +/** + * Reads the Management API's plan-gate error body, + * `{ error: { code: "entitlement_required", feature, upgrade_url } }`. + * Hand-validated: the packages/api codegen emits no non-2xx schemas. + */ +export function parsePlanGateEnvelope(body: unknown): PlanGateEntitlement | undefined { + if (typeof body !== "object" || body === null) return undefined; + const error = (body as { readonly error?: unknown }).error; + if (readString(error, "code") !== "entitlement_required") return undefined; + const feature = readString(error, "feature"); + const upgradeUrl = normalizeUpgradeUrl(readString(error, "upgrade_url")); + if (!FEATURE_KEY_PATTERN.test(feature) || upgradeUrl === undefined) return undefined; + return { feature, upgrade_url: upgradeUrl }; +} + /** * Builds an error mapper that classifies a `SupabaseApiError` into either a * typed network error or a typed unexpected-status error. Shared by command @@ -119,7 +157,7 @@ export function mapHttpError(opts: { if (cause instanceof SupabaseApiInputError || cause instanceof HttpBody.HttpBodyError) { // Client-side validation/build failures: keep their identity, since // this generic mapper can't safely reclassify them as response errors. - return yield* Effect.fail(cause); + return yield* cause; } if (HttpClientError.isHttpClientError(cause)) { if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { @@ -128,11 +166,18 @@ export function mapHttpError(opts: { Effect.orElseSucceed(() => cause.reason.description ?? ""), ); const body = sanitizeErrorBody(rawBody); + const entitlement = + status >= 400 && status < 500 + ? parsePlanGateEnvelope( + yield* cause.response.json.pipe(Effect.orElseSucceed(() => undefined)), + ) + : undefined; return yield* Effect.fail( new opts.statusError({ status, body, message: opts.statusMessage(status, body), + ...(entitlement === undefined ? {} : { entitlement }), }), ); } diff --git a/apps/cli/src/command-internal/http-errors.unit.test.ts b/apps/cli/src/command-internal/http-errors.unit.test.ts index aa430d1532..85b8058133 100644 --- a/apps/cli/src/command-internal/http-errors.unit.test.ts +++ b/apps/cli/src/command-internal/http-errors.unit.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { SupabaseApiInputError } from "@supabase/api/effect"; import { Data, Effect } from "effect"; import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClientError from "effect/http/HttpClientError"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; import { sanitizeInlineName, mapHttpError, unexpectedStatusMessage } from "./http-errors.ts"; @@ -60,6 +63,97 @@ describe("mapHttpError", () => { ); }); +const UPGRADE_URL = "https://supabase.com/dashboard/org/acme/billing"; + +function statusFailure(status: number, body: string): HttpClientError.HttpClientError { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/test/backups"); + const response = HttpClientResponse.fromWeb(request, new Response(body, { status })); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function planGateBody(error: Record, message = "Upgrade required"): string { + return JSON.stringify({ message, error: { code: "entitlement_required", ...error } }); +} + +describe("mapHttpError plan-gate entitlement", () => { + it.effect("attaches the entitlement from a gated body longer than the embedded body cap", () => + Effect.gen(function* () { + const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL }, "x".repeat(2000)); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TestStatusError", + status: 402, + entitlement: { feature: "backups", upgrade_url: UPGRADE_URL }, + }); + }), + ); + + it.effect.each(["", "Bad Gateway", '{"message":"Not Found"}'])( + "keeps the plain status error for a non-envelope 4xx body %j", + (body) => + Effect.gen(function* () { + const error = yield* mapError(statusFailure(403, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "TestStatusError", status: 403, body }); + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect("ignores a plan-gate body on a 5xx", () => + Effect.gen(function* () { + const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL }); + + const error = yield* mapError(statusFailure(503, body)).pipe(Effect.flip); + + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect.each([ + { case: "a non-web upgrade_url", feature: "backups", upgradeUrl: "javascript:alert(1)" }, + { + case: "an upgrade_url carrying credentials", + feature: "backups", + upgradeUrl: "https://supabase.com@evil.example/billing", + }, + { + case: "a feature carrying a bidi override", + feature: `backups${String.fromCodePoint(0x202e)}`, + upgradeUrl: UPGRADE_URL, + }, + ])("drops the entitlement for $case", ({ feature, upgradeUrl }) => + Effect.gen(function* () { + const body = planGateBody({ feature, upgrade_url: upgradeUrl }); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "TestStatusError", status: 402 }); + expect(error).not.toHaveProperty("entitlement"); + }), + ); + + it.effect("percent-encodes control and bidi characters in the upgrade_url", () => + Effect.gen(function* () { + const rlo = String.fromCodePoint(0x202e); + const csi = String.fromCodePoint(0x9b); + const body = planGateBody({ + feature: "backups", + upgrade_url: `${UPGRADE_URL}?ref=${rlo}${csi}`, + }); + + const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip); + + expect(error).toMatchObject({ + entitlement: { feature: "backups", upgrade_url: `${UPGRADE_URL}?ref=%E2%80%AE%C2%9B` }, + }); + }), + ); +}); + describe("unexpectedStatusMessage", () => { it("shapes the generic unexpected-status message", () => { expect(unexpectedStatusMessage(500, '{"message":"boom"}')).toBe( diff --git a/apps/cli/src/command-internal/legacy-project-state.unit.test.ts b/apps/cli/src/command-internal/legacy-project-state.unit.test.ts deleted file mode 100644 index de9281a443..0000000000 --- a/apps/cli/src/command-internal/legacy-project-state.unit.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Path } from "effect"; - -import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; -import { legacyProjectStateLayer } from "./legacy-project-state.layer.ts"; -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; -import { LegacyProjectState, ProjectRefReadError } from "./legacy-project-state.service.ts"; - -const services = legacyProjectStateLayer.pipe(Layer.provideMerge(BunServices.layer)); - -const readRef = (workdir: string) => - Effect.gen(function* () { - const state = yield* LegacyProjectState; - return yield* state.readProjectRef(workdir); - }); - -const REF = "abcdefghijklmnopqrst"; - -describe("legacyProjectStatePaths", () => { - it.effect("maps a workdir to the supabase/.temp/* layout", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const workdir = path.join(path.sep, "home", "user", "project"); - const tempDir = path.join(workdir, "supabase", ".temp"); - const paths = legacyProjectStatePaths(path, workdir); - - expect(paths.tempDir).toBe(tempDir); - expect(paths.projectRef).toBe(path.join(tempDir, "project-ref")); - expect(paths.poolerUrl).toBe(path.join(tempDir, "pooler-url")); - expect(paths.postgresVersion).toBe(path.join(tempDir, "postgres-version")); - expect(paths.restVersion).toBe(path.join(tempDir, "rest-version")); - expect(paths.gotrueVersion).toBe(path.join(tempDir, "gotrue-version")); - expect(paths.storageVersion).toBe(path.join(tempDir, "storage-version")); - expect(paths.storageMigration).toBe(path.join(tempDir, "storage-migration")); - expect(paths.linkedProjectCache).toBe(path.join(tempDir, "linked-project.json")); - expect(paths.cliLatest).toBe(path.join(tempDir, "cli-latest")); - expect(paths.pgdeltaDir).toBe(path.join(tempDir, "pgdelta")); - expect(paths.startSecretsDir).toBe(path.join(tempDir, "start-secrets")); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("every temp path is nested under tempDir", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const paths = legacyProjectStatePaths(path, "/tmp/wd"); - const { tempDir, ...rest } = paths; - for (const value of Object.values(rest)) { - expect(path.dirname(value)).toBe(tempDir); - } - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - -describe("LegacyProjectState file access", () => { - it.effect("writes a state file, reads it back, and removes the directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const state = yield* LegacyProjectState; - const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-state-" }); - - yield* state.write(workdir, "postgresVersion", "17.0.0"); - expect(yield* state.read(workdir, "postgresVersion")).toBe("17.0.0"); - expect(yield* state.exists(workdir, "postgresVersion")).toBe(true); - - yield* state.remove(workdir, "postgresVersion"); - expect(yield* state.exists(workdir, "postgresVersion")).toBe(false); - - yield* state.write(workdir, "restVersion", "v1"); - yield* state.removeAll(workdir); - expect(yield* fs.exists(state.paths(workdir).tempDir)).toBe(false); - }).pipe(Effect.scoped, Effect.provide(services)), - ); -}); - -describe("LegacyProjectState.readProjectRef", () => { - const workdir = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ref-" }); - const tempDir = path.join(dir, "supabase", ".temp"); - return { fs, dir, tempDir, refPath: path.join(tempDir, "project-ref") }; - }); - - it.effect("returns None when the project-ref file is absent (not linked)", () => - Effect.gen(function* () { - const { dir } = yield* workdir; - expect(Option.isNone(yield* readRef(dir))).toBe(true); - }).pipe(Effect.provide(services)), - ); - - it.effect("returns the trimmed ref when the file holds a value", () => - Effect.gen(function* () { - const { fs, dir, tempDir, refPath } = yield* workdir; - yield* fs.makeDirectory(tempDir, { recursive: true }); - yield* fs.writeFileString(refPath, ` ${REF}\n`); - expect(Option.getOrNull(yield* readRef(dir))).toBe(REF); - }).pipe(Effect.provide(services)), - ); - - it.effect("treats a blank project-ref file as None", () => - Effect.gen(function* () { - const { fs, dir, tempDir, refPath } = yield* workdir; - yield* fs.makeDirectory(tempDir, { recursive: true }); - yield* fs.writeFileString(refPath, " \n"); - expect(Option.isNone(yield* readRef(dir))).toBe(true); - }).pipe(Effect.provide(services)), - ); - - it.effect("fails with ProjectRefReadError when the ref path is unreadable", () => - Effect.gen(function* () { - // Seeding project-ref as a directory makes the read fail with EISDIR, which must surface - // rather than collapse to "unlinked". - const { fs, dir, refPath } = yield* workdir; - yield* fs.makeDirectory(refPath, { recursive: true }); - const error = yield* Effect.flip(readRef(dir)); - expect(error).toBeInstanceOf(ProjectRefReadError); - expect(error.message).toMatch(/^failed to load project ref: /); - }).pipe(Effect.provide(services)), - ); - - it("classifies an unreadable ref file as permission without an unrelated command", () => { - const result = classifyCliErrorActionability( - new ProjectRefReadError({ message: "failed to load project ref: permission denied" }), - ); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("permission"); - expect(result.has_suggestion).toBe(false); - expect(result.suggestion_type).toBe("none"); - expect(result.suggested_command).toBeUndefined(); - }); -}); diff --git a/apps/cli/src/command-internal/link-services-core.integration.test.ts b/apps/cli/src/command-internal/link-services-core.integration.test.ts index 34bce477c1..4e67cc3ce8 100644 --- a/apps/cli/src/command-internal/link-services-core.integration.test.ts +++ b/apps/cli/src/command-internal/link-services-core.integration.test.ts @@ -1,4 +1,4 @@ -import { legacyProjectStateLayer } from "./legacy-project-state.layer.ts"; +import { legacyProjectStateLayer } from "../shared/config/legacy-project-state.layer.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer } from "effect"; diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index c10aa43350..7cbd5e28b6 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect"; import { CommandSettings } from "../config/command-settings.service.ts"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; -import { LegacyProjectState } from "./legacy-project-state.service.ts"; +import { LegacyProjectState } from "../shared/config/legacy-project-state.service.ts"; import { fetchGotrueVersion, fetchPostgrestVersion, @@ -25,7 +25,7 @@ export interface LinkServicesInput { readonly ref: string; /** * Tenant API key used for the service version probes: `link` passes the - * service-role key, `bootstrap` passes the anon key. + * service-role key, `bootstrap` passes the anon key. Empty skips the probes. */ readonly serviceKey: string; readonly skipPooler: boolean; @@ -60,6 +60,7 @@ export const linkServicesCore = Effect.fn("LinkServices.write")(function* ( yield* linkStorageMigration(api, input.ref, state, workdir); yield* linkPooler({ api, ref: input.ref, skipPooler: input.skipPooler, state, workdir }); + if (input.serviceKey.length === 0) return; const tenantOpts = { ref: input.ref, diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index 83dc1ce133..7c824d0d96 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -1,10 +1,10 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { PROJECT_REF_PATTERN } from "../config/remote-target.service.ts"; +import { PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { findBranchName } from "./branch-target.ts"; import { formatNamedRef, sanitizeInlineName } from "./http-errors.ts"; -import { type LinkSnapshot, readLinkSnapshotSoft } from "./link-snapshot.ts"; +import { type LinkSnapshot, readLinkSnapshotSoft } from "../shared/config/link-snapshot.ts"; /** * Discriminated linked-state result. @@ -32,7 +32,7 @@ export type LinkedState = /** * Soft lookup of the currently linked ref: env `SUPABASE_PROJECT_ID`, else the checkout's link. - * Never prompts or fails, and does not depend on `RemoteTarget`, so runtimes like `status` can + * Never prompts or fails, and does not depend on `ProjectRefResolver`, so runtimes like `status` can * use it without wiring that resolver up. * * Candidates must match `PROJECT_REF_PATTERN`: the value is echoed verbatim by `status -o json` @@ -117,7 +117,7 @@ export const resolveLinkedState = Effect.fn("LinkedState.resolve")(function* () } // No cache at all: render the plain shape with zero API calls. Querying - // `RemoteTarget.parent` here would only match the ref's own default + // the linked parent here would only match the ref's own default // branch (misrendering it as "a branch of itself") or 403 when it // genuinely is a branch — there's no positive-confirmation case to attempt. return { linked: true, projectRef: linkedRef.value } as const; @@ -188,7 +188,7 @@ export function formatLinkedStateBlock(state: LinkedState): string { * what signals "not linked" for these formats. A degraded branch-linked * state still emits every field it knows; only `linked_branch` is absent. */ -export function linkedStateGoFields(state: LinkedState): Readonly> { +export function linkedStateFields(state: LinkedState): Readonly> { if (!state.linked) return {}; return { linked_project_ref: state.projectRef, @@ -202,7 +202,7 @@ export function linkedStateGoFields(state: LinkedState): Readonly { static readonly [ErrorActionabilityFingerprintId] = "InvalidJwtSecretError"; constructor() { - super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + super({ message: "Invalid config for auth.jwt_secret. Must be at least 16 characters" }); this.name = "InvalidJwtSecretError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -165,444 +150,93 @@ export class InvalidJwtSecretError extends Error { const MIN_JWT_SECRET_LENGTH = 16; /** - * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. A malformed port override always hard-fails config loading; there's - * no path that reaches `status`/`stop` with one. - */ -export class InvalidPortEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidPortEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); - this.name = "InvalidPortEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** Maximum valid port number. */ -const MAX_PORT = 65535; - -/** - * Port-flavored sibling of {@link envOverride}/{@link envOverrideBool}. Unlike the boolean - * sibling, which falls back to the configured value on a malformed override, a bad port - * override throws {@link InvalidPortEnvOverrideError} instead. - */ -export function envOverridePort( - name: string, - configuredPort: number, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configuredPort; - const parsed = parseGoBaseZeroUint(value); - if (parsed === undefined || parsed > BigInt(MAX_PORT)) { - throw new InvalidPortEnvOverrideError(dottedFieldPath, value); - } - return Number(parsed); -} - -/** - * Resolves a `SUPABASE_` override for a config field: checks `projectEnvValues` - * (the project's already-resolved dotenv values) before falling back to `process.env`, - * treating an empty value as unset. - * - * The resolved value can itself be an `env(VAR)` indirection (e.g. `env(API_ENABLED)`), - * resolved with the same precedence; an unresolved indirection is returned as-is. - */ -export function envOverride( - name: string, - configured: string, - projectEnvValues: Readonly> | undefined, -): string; -export function envOverride( - name: string, - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined; -export function envOverride( - name: string, - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined { - const value = projectEnvValues?.[name] ?? process.env[name]; - if (value === undefined || value.length === 0) return configured; - const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; - if (indirection === undefined) return value; - const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; - return resolved !== undefined && resolved.length > 0 ? resolved : value; -} - -/** - * Thrown by {@link envOverrideBool} when a `SUPABASE_*_ENABLED` (or other bool-typed) - * env/dotenv override doesn't parse as an accepted bool spelling. A malformed bool override - * always hard-fails config loading, same as {@link InvalidPortEnvOverrideError}. + * A failure that is not one of this module's config errors (a signing-key error, an unsupported + * algorithm), keeping the original error's name and message and no actionability. */ -export class InvalidBoolEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidBoolEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); - this.name = "InvalidBoolEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; +class LocalConfigValuesError extends Data.Error<{ readonly message: string }> { + constructor(message: string, name = "Error") { + super({ message }); + /* Keeps the wrapped error's name (as a non-enumerable own property) and message. */ + if (name !== "Error") Reflect.defineProperty(this, "name", { value: name, writable: true }); } } -/** - * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields decoded as a - * native bool (`api.tls.enabled`, `auth.enabled`, and other `
.enabled` gates). A - * malformed override throws {@link InvalidBoolEnvOverrideError} rather than falling back - * to `configured`. - */ -export function envOverrideBool( - name: string, - configured: boolean, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): boolean { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseGoBool(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; -} +const thrownError = (cause: unknown) => + cause instanceof ConfigValidateError || + cause instanceof InvalidJwtSecretError || + cause instanceof CliConfigValueError + ? cause + : cause instanceof Error + ? new LocalConfigValuesError(cause.message, cause.name) + : new LocalConfigValuesError(String(cause)); -/** Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` isn't `"postgres"` or `"bigquery"`. */ -export class InvalidAnalyticsBackendEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidAnalyticsBackendEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, - ); - this.name = "InvalidAnalyticsBackendEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} +const attempt = (thunk: () => A) => Effect.try({ try: thunk, catch: thrownError }); -/** - * Validates the override-or-configured value against the closed `postgres`/`bigquery` set, - * checking both sources with a single check so the return type narrows correctly. - * `skipEnvOverride` lets a matched remote-config value win over a conflicting - * `SUPABASE_ANALYTICS_BACKEND`. - */ -export function envOverrideAnalyticsBackend( +/** Narrows a configured string to one of `allowed`, failing with the codec wording otherwise. */ +export const narrowConfigEnum = ( + path: string, configured: string, - projectEnvValues: Readonly> | undefined, - skipEnvOverride = false, -): "postgres" | "bigquery" { - const value = skipEnvOverride - ? configured - : (envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured); - if (value !== "postgres" && value !== "bigquery") { - throw new InvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); - } - return value; -} - -/** Thrown by {@link envOverrideRealtimeIpVersion} when `SUPABASE_REALTIME_IP_VERSION` isn't `"IPv4"` or `"IPv6"`. */ -export class InvalidRealtimeIpVersionEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidRealtimeIpVersionEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, - ); - this.name = "InvalidRealtimeIpVersionEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverrideRealtimeIpVersion( - configured: string, - projectEnvValues: Readonly> | undefined, -): "IPv4" | "IPv6" { - const value = - envOverride("SUPABASE_REALTIME_IP_VERSION", undefined, projectEnvValues) ?? configured; - if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); - } - return value; -} - -/** `SUPABASE_REALTIME_MAX_HEADER_LENGTH` — see {@link envOverrideUint}. */ -export function envOverrideRealtimeMaxHeaderLength( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_REALTIME_MAX_HEADER_LENGTH", - "realtime.max_header_length", - configured, - projectEnvValues, - ); -} - -/** `SUPABASE_API_MAX_ROWS` — see {@link envOverrideUint}. */ -export function envOverrideApiMaxRows( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint("SUPABASE_API_MAX_ROWS", "api.max_rows", configured, projectEnvValues); -} - -/** Thrown by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` isn't `"transaction"` or `"session"`. */ -export class InvalidPoolModeEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidPoolModeEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, - ); - this.name = "InvalidPoolModeEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverridePoolMode( - configured: string, - projectEnvValues: Readonly> | undefined, -): "transaction" | "session" { - const value = - envOverride("SUPABASE_DB_POOLER_POOL_MODE", undefined, projectEnvValues) ?? configured; - if (value !== "transaction" && value !== "session") { - throw new InvalidPoolModeEnvOverrideError("db.pooler.pool_mode", value); - } - return value; -} - -/** Thrown by {@link envOverrideEdgeRuntimePolicy} when `SUPABASE_EDGE_RUNTIME_POLICY` isn't `"per_worker"` or `"oneshot"`. */ -export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidEdgeRuntimePolicyEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, - ); - this.name = "InvalidEdgeRuntimePolicyEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverrideEdgeRuntimePolicy( - configured: string, - projectEnvValues: Readonly> | undefined, -): "per_worker" | "oneshot" { - const value = - envOverride("SUPABASE_EDGE_RUNTIME_POLICY", undefined, projectEnvValues) ?? configured; - if (value !== "per_worker" && value !== "oneshot") { - throw new InvalidEdgeRuntimePolicyEnvOverrideError("edge_runtime.policy", value); + allowed: ReadonlyArray, +): T => { + const match = allowed.find((candidate) => candidate === configured); + if (match === undefined) { + throw new CliConfigValueError({ + path, + tier: "config", + message: `Invalid config for ${path}: "${configured}" must be one of ${allowed + .map((value) => `"${value}"`) + .join(", ")}`, + }); } - return value; -} + return match; +}; -/** `SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE` — see {@link envOverrideUint}. */ -export function envOverrideDefaultPoolSize( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", - "db.pooler.default_pool_size", - configured, - projectEnvValues, - ); +/** Narrows an unknown value to a plain object. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; } -/** `SUPABASE_DB_POOLER_MAX_CLIENT_CONN` — see {@link envOverrideUint}. */ -export function envOverrideMaxClientConn( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_POOLER_MAX_CLIENT_CONN", - "db.pooler.max_client_conn", - configured, - projectEnvValues, - ); -} - -/** - * Decrypts a resolved auth identity-key field when it's a dotenvx `encrypted:` value; an - * undecryptable value fails config loading rather than passing through unusable key material. - * Applied after {@link envOverride} so an env-sourced override is decrypted too, not just - * the config.toml value. - */ -export function decryptAuthSecret( - value: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined { - if (value === undefined || !isEncryptedSecret(value)) return value; - const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnvValues, ...process.env }); - const decrypted = decryptSecret(value, dotenvPrivateKeys); - if (!decrypted.ok) { - throw new ConfigValidateError(`failed to parse config: ${decrypted.error}`); - } - return decrypted.value; -} +const asString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; /** * Resolves `[auth.email.smtp]`'s full field set, including a presence-based `enabled` * default `@supabase/config`'s schema can't express (it always decodes `enabled: false` - * when the key is absent from a present table). Exported so `start.handler.ts`'s GoTrue env - * resolution reuses this instead of re-deriving it. + * when the key is absent from a present table). */ export function resolveAuthEmailSmtp( authDocument: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool`/`envOverridePort` calls below can throw on - * a malformed value (directly, or via {@link decryptAuthSecret}) belong in this set so a - * matched remote block's value wins instead of aborting on an unrelated bad env var. - * Defaults to empty for callers with no `[remotes.]` block to match against. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): (SmtpInput & { readonly senderName: string | undefined }) | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); if (smtpDoc === undefined) return undefined; return { - enabled: remoteWins("auth.email.smtp.enabled") - ? smtpDoc["enabled"] === undefined - ? true - : smtpDoc["enabled"] === true - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", - smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, - "auth.email.smtp.enabled", - projectEnvValues, - ), - host: remoteWins("auth.email.smtp.host") - ? typeof smtpDoc["host"] === "string" - ? smtpDoc["host"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_HOST", - typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", - projectEnvValues, - ) ?? ""), - port: remoteWins("auth.email.smtp.port") - ? typeof smtpDoc["port"] === "number" - ? smtpDoc["port"] - : 0 - : envOverridePort( - "SUPABASE_AUTH_EMAIL_SMTP_PORT", - typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, - "auth.email.smtp.port", - projectEnvValues, - ), - user: remoteWins("auth.email.smtp.user") - ? typeof smtpDoc["user"] === "string" - ? smtpDoc["user"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_USER", - typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", - projectEnvValues, - ) ?? ""), - // Decrypted like other secrets. `auth.email.smtp.pass` is in `ENV_OVERRIDABLE_KEYS` so a - // malformed ambient override can't abort decryption when a remote block already set it. - pass: remoteWins("auth.email.smtp.pass") - ? (decryptAuthSecret( - typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", - projectEnvValues, - ) ?? "") - : (decryptAuthSecret( - envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_PASS", - typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", - projectEnvValues, - ) ?? "", - projectEnvValues, - ) ?? ""), - adminEmail: remoteWins("auth.email.smtp.admin_email") - ? typeof smtpDoc["admin_email"] === "string" - ? smtpDoc["admin_email"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", - typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", - projectEnvValues, - ) ?? ""), - senderName: remoteWins("auth.email.smtp.sender_name") - ? typeof smtpDoc["sender_name"] === "string" - ? smtpDoc["sender_name"] - : undefined - : envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", - typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined, - projectEnvValues, - ), + enabled: smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + host: asString(smtpDoc["host"]) ?? "", + port: typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + user: asString(smtpDoc["user"]) ?? "", + pass: asString(smtpDoc["pass"]) ?? "", + adminEmail: asString(smtpDoc["admin_email"]) ?? "", + senderName: asString(smtpDoc["sender_name"]), }; } /** - * `auth.captcha` validation requires both `provider` and `secret` when the section is - * enabled. Unlike `auth.passkey`/`auth.webauthn`, `config.auth.captcha` never decodes to - * `undefined` when `[auth.captcha]` is absent, so presence is read from the raw - * `authDocument` instead. Hoisted so `start.handler.ts`'s GoTrue env resolution shares this - * same resolved value. + * `config.auth.captcha` never decodes to `undefined` when `[auth.captcha]` is absent, so + * callers that need presence read the document; this only reshapes the effective value. */ export function resolveAuthCaptcha( - authDocument: Readonly> | undefined, captcha: CliConfig["auth"]["captcha"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool` calls below can throw on a malformed value - * (directly, or via {@link decryptAuthSecret}) belong in this set so a matched remote - * block's value wins instead of aborting on an unrelated bad env var. Defaults to empty for - * callers with no `[remotes.]` block to match against. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): CaptchaInput | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const captchaDoc = asRecord(authDocument?.["captcha"]); return captcha - ? { - enabled: remoteWins("auth.captcha.enabled") - ? (captcha.enabled ?? false) - : captchaDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_CAPTCHA_ENABLED", - captcha.enabled ?? false, - "auth.captcha.enabled", - projectEnvValues, - ) - : (captcha.enabled ?? false), - provider: remoteWins("auth.captcha.provider") - ? captcha.provider - : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues) - : captcha.provider, - // Decrypted like `auth.email.smtp.pass`. `auth.captcha.secret` is in - // `ENV_OVERRIDABLE_KEYS` so a malformed ambient override can't abort decryption when a - // remote block already set it. - secret: decryptAuthSecret( - remoteWins("auth.captcha.secret") - ? captcha.secret - : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) - : captcha.secret, - projectEnvValues, - ), - } + ? { enabled: captcha.enabled ?? false, provider: captcha.provider, secret: captcha.secret } : undefined; } /** - * Resolves the signing secret from the already-resolved `auth.jwt_secret`: empty falls back - * to {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws + * Resolves the signing secret from the effective `auth.jwt_secret`: empty falls back to + * {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws * {@link InvalidJwtSecretError}. */ export function resolveJwtSecret(configured: string | undefined): string { @@ -625,8 +259,8 @@ function resolveSignedKey( ): string { if (configured !== undefined && configured.length > 0) return configured; return signingKey !== undefined - ? generateAsymmetricGoJwt(signingKey, role) - : generateGoJwt(jwtSecret, role); + ? generateAsymmetricLocalJwt(signingKey, role) + : generateLocalJwt(jwtSecret, role); } /** JWK fields, matching {@link Jwk}. */ @@ -649,105 +283,90 @@ const JwkSchema = Schema.Struct({ x: Schema.optionalKey(Schema.String), y: Schema.optionalKey(Schema.String), }); -const decodeJwks = Schema.decodeUnknownSync(Schema.Array(JwkSchema)); +const decodeJwks = Schema.decodeUnknownEffect(Schema.Array(JwkSchema)); /** - * Reads and JSON-decodes `signingKeysPath` into an array of {@link Jwk}, using `node:fs` - * directly (not the `FileSystem` Effect service) to keep this a plain synchronous resolver - * for a rarely-configured field. Callers must only invoke this when auth is enabled. + * Reads and JSON-decodes `signingKeysPath` into an array of {@link Jwk}, keeping a leading BOM + * (no BOM stripping). Callers must only invoke this when auth is enabled. */ -function readSigningKeysFile(workdir: string, signingKeysPath: string): ReadonlyArray { - const absolutePath = resolveSigningKeysPath(workdir, signingKeysPath); - - let contents: string; - try { - contents = readFileSync(absolutePath, "utf8"); - } catch (cause) { - throw new ConfigValidateError(signingKeysReadErrorMessage(cause)); - } +const readSigningKeysFile = Effect.fnUntraced(function* (workdir: string, signingKeysPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = resolveSigningKeysPath(path, workdir, signingKeysPath); + + const contents = yield* fs.readFile(absolutePath).pipe( + Effect.map((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)), + Effect.mapError( + (cause) => new ConfigValidateError(signingKeysReadErrorMessage(fsErrorCause(cause))), + ), + ); - try { + const parsed = yield* Effect.try({ // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native parser errors are CLI output; schema decoding discards their messages. - const parsed: unknown = JSON.parse(contents); - // `Jwk.key_ops` is mutable (required for Node's `createPrivateKey`/`JsonWebKey` input), so - // it's copied into a fresh array rather than widening the schema's readonly output type. - return decodeJwks(parsed).map((jwk) => ({ - ...jwk, - key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], - })); - } catch (cause) { - throw new ConfigValidateError(signingKeysDecodeErrorMessage(cause)); - } -} - -/** See {@link readSigningKeysFile}. */ -function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArray { - return readSigningKeysFile(workdir, signingKeysPath); -} + try: (): unknown => JSON.parse(contents), + catch: (cause) => new ConfigValidateError(signingKeysDecodeErrorMessage(cause)), + }); + const jwks = yield* decodeJwks(parsed).pipe( + Effect.mapError((cause) => new ConfigValidateError(signingKeysDecodeErrorMessage(cause))), + ); + // `Jwk.key_ops` is mutable (required for Node's `createPrivateKey`/`JsonWebKey` input), so + // it's copied into a fresh array rather than widening the schema's readonly output type. + return jwks.map((jwk) => ({ + ...jwk, + key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], + })) satisfies ReadonlyArray; +}); /** * Returns the parsed signing keys only when auth is enabled and a path is configured; * `undefined` otherwise, so callers fall back to their own default key shape. Shared by * {@link resolveLocalJwks} and `start.handler.ts`'s `GOTRUE_JWT_KEYS` so both resolvers agree - * on which key(s) apply. `remoteOverrideKeys` lets a matched remote block win over a - * conflicting env override on `auth.enabled`/`auth.signing_keys_path`. + * on which key(s) apply. */ -export function resolveConfiguredSigningKeys( +export const resolveConfiguredSigningKeys = Effect.fnUntraced(function* ( config: CliConfig, workdir: string, - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), -): ReadonlyArray | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 - ? loadSigningKeys(workdir, signingKeysPath) +) { + const signingKeysPath = config.auth.signing_keys_path; + return config.auth.enabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? yield* readSigningKeysFile(workdir, signingKeysPath) : undefined; -} +}); /** * Confirms `api.tls.cert`/`.key` are readable when both are configured; the "exactly one set" * check lives in `validateResolvedConfig`. Unlike {@link readSigningKeysFile}'s * `signing_keys_path`, both paths join with the `supabase/` dir without an absolute-path guard. */ -function readApiTlsFiles( +const readApiTlsFiles = Effect.fnUntraced(function* ( workdir: string, certPath: string | undefined, keyPath: string | undefined, -): void { +) { if (certPath === undefined || certPath.length === 0) return; if (keyPath === undefined || keyPath.length === 0) return; - try { - readFileSync(resolveApiTlsPath(workdir, certPath), "utf8"); - } catch (cause) { - throw new ConfigValidateError(apiTlsCertReadErrorMessage(cause)); - } - try { - readFileSync(resolveApiTlsPath(workdir, keyPath), "utf8"); - } catch (cause) { - throw new ConfigValidateError(apiTlsKeyReadErrorMessage(cause)); - } -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs + .readFile(resolveApiTlsPath(path, workdir, certPath)) + .pipe( + Effect.mapError( + (cause) => new ConfigValidateError(apiTlsCertReadErrorMessage(fsErrorCause(cause))), + ), + ); + yield* fs + .readFile(resolveApiTlsPath(path, workdir, keyPath)) + .pipe( + Effect.mapError( + (cause) => new ConfigValidateError(apiTlsKeyReadErrorMessage(fsErrorCause(cause))), + ), + ); +}); /** - * One `[auth.email.template.]` entry, already env-override-resolved. `subject` is - * `string | undefined` rather than a plain `string` — see {@link resolveAuthEmail}'s doc - * comment for why. + * One `[auth.email.template.]` entry. `subject` is `string | undefined` rather than a plain + * `string` — see {@link resolveAuthEmail} for why. */ interface ResolvedAuthEmailTemplate { readonly subject: string | undefined; @@ -774,139 +393,40 @@ export type ResolvedAuthEmail = Omit | undefined, - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool` calls (here or in the caller-side file read - * for `content_path`) can throw on a malformed value belong in this set so a matched remote - * block's value wins instead of aborting on an unrelated bad env var. Covers top-level - * `auth.email.*` leaves as well as each `template..*`/`notification..*` leaf. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): ResolvedAuthEmail { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const emailDoc = asRecord(authDocument?.["email"]); const templateDoc = asRecord(emailDoc?.["template"]); const notificationDoc = asRecord(emailDoc?.["notification"]); const template: Record = {}; for (const [name, tmpl] of Object.entries(email.template)) { - const envPrefix = `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}`; - const rawSubjectPresent = asRecord(templateDoc?.[name])?.["subject"] !== undefined; - const envSubject = remoteWins(`auth.email.template.${name}.subject`) - ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + const entry = asRecord(templateDoc?.[name]); template[name] = { - subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: remoteWins(`auth.email.template.${name}.content_path`) - ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path), - // `content` counts as present when set via TOML or the `_CONTENT` env override; - // {@link readAuthEmailTemplateContent} rejects it below unless `content_path` is also set. - content_present: - asRecord(templateDoc?.[name])?.["content"] !== undefined || - (remoteWins(`auth.email.template.${name}.content`) - ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + subject: entry?.["subject"] !== undefined ? tmpl.subject : undefined, + content_path: tmpl.content_path, + content_present: entry?.["content"] !== undefined, }; } const notification: Record = {}; for (const [name, tmpl] of Object.entries(email.notification)) { - const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; - const rawSubjectPresent = asRecord(notificationDoc?.[name])?.["subject"] !== undefined; - const envSubject = remoteWins(`auth.email.notification.${name}.subject`) - ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + const entry = asRecord(notificationDoc?.[name]); notification[name] = { - enabled: remoteWins(`auth.email.notification.${name}.enabled`) - ? tmpl.enabled - : envOverrideBool( - `${envPrefix}_ENABLED`, - tmpl.enabled, - `auth.email.notification.${name}.enabled`, - projectEnvValues, - ), - subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: remoteWins(`auth.email.notification.${name}.content_path`) - ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path), - // Same `_CONTENT` env-presence fold as the template loop above. - content_present: - asRecord(notificationDoc?.[name])?.["content"] !== undefined || - (remoteWins(`auth.email.notification.${name}.content`) - ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + enabled: tmpl.enabled, + subject: entry?.["subject"] !== undefined ? tmpl.subject : undefined, + content_path: tmpl.content_path, + content_present: entry?.["content"] !== undefined, }; } - return { - ...email, - enable_signup: remoteWins("auth.email.enable_signup") - ? email.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", - email.enable_signup, - "auth.email.enable_signup", - projectEnvValues, - ), - double_confirm_changes: remoteWins("auth.email.double_confirm_changes") - ? email.double_confirm_changes - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", - email.double_confirm_changes, - "auth.email.double_confirm_changes", - projectEnvValues, - ), - enable_confirmations: remoteWins("auth.email.enable_confirmations") - ? email.enable_confirmations - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", - email.enable_confirmations, - "auth.email.enable_confirmations", - projectEnvValues, - ), - secure_password_change: remoteWins("auth.email.secure_password_change") - ? email.secure_password_change - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", - email.secure_password_change, - "auth.email.secure_password_change", - projectEnvValues, - ), - max_frequency: remoteWins("auth.email.max_frequency") - ? email.max_frequency - : (envOverride("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", email.max_frequency, projectEnvValues) ?? - email.max_frequency), - otp_length: remoteWins("auth.email.otp_length") - ? email.otp_length - : envOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_LENGTH", - "auth.email.otp_length", - email.otp_length, - projectEnvValues, - ), - otp_expiry: remoteWins("auth.email.otp_expiry") - ? email.otp_expiry - : envOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", - "auth.email.otp_expiry", - email.otp_expiry, - projectEnvValues, - ), - template, - notification, - }; + return { ...email, template, notification }; } /** @@ -915,478 +435,74 @@ export function resolveAuthEmail( * `content`-vs-`content_path` exclusivity and path resolution live in * `resolveEmailTemplateContentPath`; this only performs the read once a path comes back. */ -function readAuthEmailTemplateContent(email: ResolvedAuthEmail, workdir: string): void { +const readAuthEmailTemplateContent = Effect.fnUntraced(function* ( + email: ResolvedAuthEmail, + workdir: string, +) { + const fs = yield* FileSystem.FileSystem; for (const [name, tmpl] of Object.entries(email.template)) { - const path = resolveEmailTemplateContentPath({ + const path = yield* resolveEmailTemplateContentPath({ section: "template", name, contentPath: tmpl.content_path, contentPresent: tmpl.content_present, base: workdir, - }); + }).pipe( + Effect.mapError((cause) => + cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), + ), + ); if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new ConfigValidateError(emailContentPathReadErrorMessage("template", name, cause)); - } + yield* fs + .readFile(path) + .pipe( + Effect.mapError( + (cause) => + new ConfigValidateError( + emailContentPathReadErrorMessage("template", name, fsErrorCause(cause)), + ), + ), + ); } for (const [name, tmpl] of Object.entries(email.notification)) { if (!tmpl.enabled) continue; - const path = resolveEmailTemplateContentPath({ + const path = yield* resolveEmailTemplateContentPath({ section: "notification", name, contentPath: tmpl.content_path, contentPresent: tmpl.content_present, base: workdir, - }); + }).pipe( + Effect.mapError((cause) => + cause instanceof ConfigValidateError ? cause : thrownError(fsErrorCause(cause)), + ), + ); if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new ConfigValidateError(emailContentPathReadErrorMessage("notification", name, cause)); - } - } -} - -// Every field routed through {@link envOverrideUint} is an unsigned 64-bit value; comparing -// as `BigInt` (not `Number`) avoids precision loss near `2^64`. -const UINT_MAX = 18446744073709551615n; // 2^64 - 1 - -/** - * Base-0 unsigned integer literal parsing (`0b`/`0o`/`0x` prefixes, and a bare leading zero - * also meaning octal — so `"010"` parses as `8`), matching Go's `strconv.ParseUint(str, 0, …)` - * grammar. Underscores between digits are allowed; a leading sign is never accepted. Returns - * `undefined` for anything invalid instead of throwing, leaving bit-width bounds to the caller. - */ -function parseGoBaseZeroUint(value: string): bigint | undefined { - if (value.length === 0 || value.startsWith("+") || value.startsWith("-")) return undefined; - - let literal: string | undefined; - if (/^0[bB](_?[01])+$/.test(value)) { - literal = `0b${value.slice(2).replaceAll("_", "")}`; - } else if (/^0[oO](_?[0-7])+$/.test(value)) { - literal = `0o${value.slice(2).replaceAll("_", "")}`; - } else if (/^0[xX](_?[0-9a-fA-F])+$/.test(value)) { - literal = `0x${value.slice(2).replaceAll("_", "")}`; - } else if (value.startsWith("0") && value.length > 1) { - // A bare leading zero is always octal, with no fallback to decimal — `"08"`/`"09"` are - // rejected, not read as decimal 8/9. - literal = /^[0-7](_?[0-7])*$/.test(value) ? `0o${value.replaceAll("_", "")}` : undefined; - } else { - literal = /^[0-9](_?[0-9])*$/.test(value) ? value.replaceAll("_", "") : undefined; - } - if (literal === undefined) return undefined; - - try { - // `BigInt` natively parses `0b`/`0o`/`0x`-prefixed literals in the corresponding base. - return BigInt(literal); - } catch { - return undefined; - } -} - -/** - * `SUPABASE_` sibling of {@link envOverridePort} for uncapped `uint`-typed fields - * (`db.major_version`, `auth.jwt_expiry`, …). Parses with {@link parseGoBaseZeroUint} and - * folds an invalid or out-of-{@link UINT_MAX} override into the generic "Invalid " - * error message. - */ -export function envOverrideUint( - name: string, - dottedFieldPath: string, - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseGoBaseZeroUint(value); - if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); + yield* fs + .readFile(path) + .pipe( + Effect.mapError( + (cause) => + new ConfigValidateError( + emailContentPathReadErrorMessage("notification", name, fsErrorCause(cause)), + ), + ), + ); } - return Number(parsed); -} - -/** `SUPABASE_DB_MAJOR_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideMajorVersion( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_MAJOR_VERSION", - "db.major_version", - configured, - projectEnvValues, - ); -} - -const ORIOLEDB_VERSION_ENV = "SUPABASE_DB_ORIOLEDB_VERSION"; -const DEPRECATED_ORIOLEDB_VERSION_ENV = "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"; - -/** - * Reads the `db.orioledb_version` override through `read`, falling back to the deprecated - * `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` alias while `SUPABASE_DB_ORIOLEDB_VERSION` is unset. - */ -export const readOrioleDBVersionEnvOverride = ( - read: ( - name: typeof ORIOLEDB_VERSION_ENV | typeof DEPRECATED_ORIOLEDB_VERSION_ENV, - ) => string | undefined, -): string | undefined => read(ORIOLEDB_VERSION_ENV) ?? read(DEPRECATED_ORIOLEDB_VERSION_ENV); - -/** `SUPABASE_DB_ORIOLEDB_VERSION` or its deprecated alias — see {@link envOverride}. */ -export function envOverrideOrioleDBVersion( - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined { - return ( - readOrioleDBVersionEnvOverride((name) => envOverride(name, undefined, projectEnvValues)) ?? - configured - ); -} - -/** - * Set by {@link withSingleOrioleDBVersionEnvWarning}; while unset, every config read that finds the - * deprecated OrioleDB env alias prints its WARN. - */ -const OrioleDBVersionEnvWarningPrinted = Context.Reference>>( - "supabase/cli/OrioleDBVersionEnvWarningPrinted", - { defaultValue: () => Option.none() }, -); - -/** Runs `effect` so its config reads print the deprecated OrioleDB env WARN at most once. */ -export const withSingleOrioleDBVersionEnvWarning = (effect: Effect.Effect) => - Effect.flatMap(Ref.make(false), (printed) => - Effect.provideService(effect, OrioleDBVersionEnvWarningPrinted, Option.some(printed)), - ); - -/** - * The deprecation warning to print when `read` finds `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` set, - * unless an enclosing {@link withSingleOrioleDBVersionEnvWarning} already printed it. - */ -export const deprecatedOrioleDBVersionEnvWarning = Effect.fnUntraced(function* ( - read: (name: typeof DEPRECATED_ORIOLEDB_VERSION_ENV) => string | undefined, -) { - if (read(DEPRECATED_ORIOLEDB_VERSION_ENV) === undefined) return undefined; - const printed = yield* OrioleDBVersionEnvWarningPrinted; - if (Option.isSome(printed) && (yield* Ref.getAndSet(printed.value, true))) return undefined; - return `WARN: ${DEPRECATED_ORIOLEDB_VERSION_ENV} is deprecated and will be removed in v3, use ${ORIOLEDB_VERSION_ENV} instead.`; }); -/** `SUPABASE_EDGE_RUNTIME_DENO_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideDenoVersion( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "edge_runtime.deno_version", - configured, - projectEnvValues, - ); -} - -/** - * Optional-uint sibling of {@link envOverrideUint} for `db.settings.*` fields - * (`max_connections`, `max_wal_senders`, …) that are left unset, not defaulted, when - * absent from config.toml. `configured`/the return value stay `number | undefined` to - * preserve that state through an override miss. - */ -function envOverrideOptionalUint( - name: string, - dottedFieldPath: string, - configured: number | undefined, - projectEnvValues: Readonly> | undefined, -): number | undefined { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseGoBaseZeroUint(value); - if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); - } - return Number(parsed); -} - -/** - * Optional-bool sibling of {@link envOverrideBool} for `db.settings.track_commit_timestamp`, - * the only bool field in this file left unset (not defaulted) when absent. - */ -function envOverrideOptionalBool( - name: string, - configured: boolean | undefined, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): boolean | undefined { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseGoBool(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; -} - -/** Thrown by {@link resolveDbSettingsEnvOverrides} when `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` isn't `"origin"`, `"replica"`, or `"local"`. */ -export class InvalidSessionReplicationRoleEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = - "InvalidSessionReplicationRoleEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, - ); - this.name = "InvalidSessionReplicationRoleEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Enum-flavored sibling of {@link envOverride} for `db.settings.session_replication_role`. - * Unlike {@link envOverrideAnalyticsBackend}, `configured` (and the return value) may - * be `undefined` — validation only runs once a value is actually present. - */ -function envOverrideSessionReplicationRole( - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): "origin" | "replica" | "local" | undefined { - const value = envOverride( - "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", - configured, - projectEnvValues, - ); - if (value === undefined) return undefined; - if (value !== "origin" && value !== "replica" && value !== "local") { - throw new InvalidSessionReplicationRoleEnvOverrideError( - "db.settings.session_replication_role", - value, - ); - } - return value; -} - -/** - * Resolves every `db.settings.*` sub-field to its env-overridden value before - * `postgresSettingsToPostgresConfig` serializes `postgresql.conf`. `remoteOverrideKeys` - * lets a matched remote block's value win over a conflicting `SUPABASE_DB_SETTINGS_*` var, - * same as elsewhere in this file. - */ +/** The effective `db.settings`, never `undefined` so callers can read fields directly. */ export function resolveDbSettingsEnvOverrides( settings: CliConfig["db"]["settings"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): NonNullable { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - return { - effective_cache_size: remoteWins("db.settings.effective_cache_size") - ? settings?.effective_cache_size - : envOverride( - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - settings?.effective_cache_size, - projectEnvValues, - ), - logical_decoding_work_mem: remoteWins("db.settings.logical_decoding_work_mem") - ? settings?.logical_decoding_work_mem - : envOverride( - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - settings?.logical_decoding_work_mem, - projectEnvValues, - ), - maintenance_work_mem: remoteWins("db.settings.maintenance_work_mem") - ? settings?.maintenance_work_mem - : envOverride( - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - settings?.maintenance_work_mem, - projectEnvValues, - ), - max_connections: remoteWins("db.settings.max_connections") - ? settings?.max_connections - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "db.settings.max_connections", - settings?.max_connections, - projectEnvValues, - ), - max_locks_per_transaction: remoteWins("db.settings.max_locks_per_transaction") - ? settings?.max_locks_per_transaction - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "db.settings.max_locks_per_transaction", - settings?.max_locks_per_transaction, - projectEnvValues, - ), - max_parallel_maintenance_workers: remoteWins("db.settings.max_parallel_maintenance_workers") - ? settings?.max_parallel_maintenance_workers - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "db.settings.max_parallel_maintenance_workers", - settings?.max_parallel_maintenance_workers, - projectEnvValues, - ), - max_parallel_workers: remoteWins("db.settings.max_parallel_workers") - ? settings?.max_parallel_workers - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "db.settings.max_parallel_workers", - settings?.max_parallel_workers, - projectEnvValues, - ), - max_parallel_workers_per_gather: remoteWins("db.settings.max_parallel_workers_per_gather") - ? settings?.max_parallel_workers_per_gather - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "db.settings.max_parallel_workers_per_gather", - settings?.max_parallel_workers_per_gather, - projectEnvValues, - ), - max_replication_slots: remoteWins("db.settings.max_replication_slots") - ? settings?.max_replication_slots - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "db.settings.max_replication_slots", - settings?.max_replication_slots, - projectEnvValues, - ), - max_slot_wal_keep_size: remoteWins("db.settings.max_slot_wal_keep_size") - ? settings?.max_slot_wal_keep_size - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - settings?.max_slot_wal_keep_size, - projectEnvValues, - ), - max_standby_archive_delay: remoteWins("db.settings.max_standby_archive_delay") - ? settings?.max_standby_archive_delay - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - settings?.max_standby_archive_delay, - projectEnvValues, - ), - max_standby_streaming_delay: remoteWins("db.settings.max_standby_streaming_delay") - ? settings?.max_standby_streaming_delay - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - settings?.max_standby_streaming_delay, - projectEnvValues, - ), - max_wal_size: remoteWins("db.settings.max_wal_size") - ? settings?.max_wal_size - : envOverride("SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", settings?.max_wal_size, projectEnvValues), - max_wal_senders: remoteWins("db.settings.max_wal_senders") - ? settings?.max_wal_senders - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "db.settings.max_wal_senders", - settings?.max_wal_senders, - projectEnvValues, - ), - max_worker_processes: remoteWins("db.settings.max_worker_processes") - ? settings?.max_worker_processes - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "db.settings.max_worker_processes", - settings?.max_worker_processes, - projectEnvValues, - ), - session_replication_role: remoteWins("db.settings.session_replication_role") - ? settings?.session_replication_role - : envOverrideSessionReplicationRole(settings?.session_replication_role, projectEnvValues), - shared_buffers: remoteWins("db.settings.shared_buffers") - ? settings?.shared_buffers - : envOverride( - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - settings?.shared_buffers, - projectEnvValues, - ), - statement_timeout: remoteWins("db.settings.statement_timeout") - ? settings?.statement_timeout - : envOverride( - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - settings?.statement_timeout, - projectEnvValues, - ), - track_activity_query_size: remoteWins("db.settings.track_activity_query_size") - ? settings?.track_activity_query_size - : envOverride( - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - settings?.track_activity_query_size, - projectEnvValues, - ), - track_commit_timestamp: remoteWins("db.settings.track_commit_timestamp") - ? settings?.track_commit_timestamp - : envOverrideOptionalBool( - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - settings?.track_commit_timestamp, - "db.settings.track_commit_timestamp", - projectEnvValues, - ), - wal_keep_size: remoteWins("db.settings.wal_keep_size") - ? settings?.wal_keep_size - : envOverride( - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - settings?.wal_keep_size, - projectEnvValues, - ), - wal_sender_timeout: remoteWins("db.settings.wal_sender_timeout") - ? settings?.wal_sender_timeout - : envOverride( - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - settings?.wal_sender_timeout, - projectEnvValues, - ), - work_mem: remoteWins("db.settings.work_mem") - ? settings?.work_mem - : envOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), - }; -} - -/** `password_requirements` fixed enum (`@supabase/config`'s `packages/config/src/auth/index.ts`). */ -const PASSWORD_REQUIREMENTS_VALUES = new Set([ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols", -]); - -/** - * Enum-flavored sibling of {@link envOverride} for `auth.password_requirements`. Exported so - * `db start`'s own eager-validation battery can call it directly instead of duplicating the - * check. - */ -export function envOverrideAuthPasswordRequirements( - configured: string, - projectEnvValues: Readonly> | undefined, -): string { - const override = envOverride("SUPABASE_AUTH_PASSWORD_REQUIREMENTS", undefined, projectEnvValues); - if (override !== undefined && !PASSWORD_REQUIREMENTS_VALUES.has(override)) { - throw new Error(`Failed reading config: Invalid auth.password_requirements: ${override}.`); - } - return override ?? configured; -} - -/** Narrows an unknown value to a plain object. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; + return settings ?? {}; } -/** - * Resolves `auth.external_url`, not modeled in `@supabase/config`'s schema, from the raw - * document. Shared by `supabase start`'s long-running GoTrue container and `db start`'s - * one-shot auth migration job so both resolve the same value. `remoteOverrideKeys` lets a - * matched remote block win over a conflicting `SUPABASE_AUTH_EXTERNAL_URL`. - */ +/** Resolves `auth.external_url`, which the schema doesn't model, from the effective document. */ export function resolveAuthExternalUrl( document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): string | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; - const configuredAuthExternalUrl = - typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined; - if (remoteWins("auth.external_url")) return configuredAuthExternalUrl; - return envOverride("SUPABASE_AUTH_EXTERNAL_URL", configuredAuthExternalUrl, projectEnvValues); + return asString(asRecord(document?.["auth"])?.["external_url"]); } /** Hook-type iteration order for {@link resolveAuthHooks}'s output. */ @@ -1419,246 +535,45 @@ export type ResolvedAuthHooks = { readonly [K in (typeof HOOK_TYPE_TO_CAMEL)[keyof typeof HOOK_TYPE_TO_CAMEL]]: ResolvedAuthHook; }; -/** - * Resolves `auth.hook..*` overrides in {@link HOOK_TYPE_ORDER}. Each type's presence in - * the raw `authDocument` gates whether an env override applies, since the schema always - * decodes a `{ enabled: false }` default regardless of file presence. Hoisted so both - * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same - * result. - */ -export function resolveAuthHooks( - authDocument: Readonly> | undefined, - hook: CliConfig["auth"]["hook"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose overrides below can throw or otherwise cause a downstream rejection on a - * malformed value belong in this set so a matched remote block's value wins instead. A - * matched remote flattens the whole `auth.hook.` block, so every leaf - * (`enabled`/`uri`/`secrets`) is included, not just `enabled`. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): ResolvedAuthHooks { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const hookDocument = asRecord(authDocument?.["hook"]); +/** Reshapes the effective `auth.hook.` entries, with absent `uri`/`secrets` as `""`. */ +export function resolveAuthHooks(hook: CliConfig["auth"]["hook"]): ResolvedAuthHooks { const result = {} as Record; for (const hookType of HOOK_TYPE_ORDER) { const h = hook[hookType]; - const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; - const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; - const enabled = remoteWins(`auth.hook.${hookType}.enabled`) - ? h.enabled - : hookSectionPresent - ? envOverrideBool( - `${envPrefix}_ENABLED`, - h.enabled, - `auth.hook.${hookType}.enabled`, - projectEnvValues, - ) - : h.enabled; - const uri = - (remoteWins(`auth.hook.${hookType}.uri`) - ? h.uri - : hookSectionPresent - ? envOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) - : h.uri) ?? ""; - const secrets = - (remoteWins(`auth.hook.${hookType}.secrets`) - ? h.secrets - : hookSectionPresent - ? envOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) - : h.secrets) ?? ""; - result[HOOK_TYPE_TO_CAMEL[hookType]] = { enabled, uri, secrets }; + result[HOOK_TYPE_TO_CAMEL[hookType]] = { + enabled: h.enabled, + uri: h.uri ?? "", + secrets: h.secrets ?? "", + }; } return result as ResolvedAuthHooks; } -/** - * Resolves `auth.mfa`'s per-factor fields. Unlike hooks/smtp, `auth.mfa.` is always - * bound (no schema presence gap), so overrides always apply. Hoisted so both - * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same - * result. - */ -export function resolveAuthMfa( - mfa: CliConfig["auth"]["mfa"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`envOverrideUint`/`envOverride` calls below can throw or - * otherwise cause a downstream rejection on a malformed value belong in this set so a - * matched remote block's value wins instead of aborting on an unrelated bad env var. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["mfa"] { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - return { - totp: { - enroll_enabled: remoteWins("auth.mfa.totp.enroll_enabled") - ? mfa.totp.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", - mfa.totp.enroll_enabled, - "auth.mfa.totp.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.totp.verify_enabled") - ? mfa.totp.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", - mfa.totp.verify_enabled, - "auth.mfa.totp.verify_enabled", - projectEnvValues, - ), - }, - phone: { - enroll_enabled: remoteWins("auth.mfa.phone.enroll_enabled") - ? mfa.phone.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", - mfa.phone.enroll_enabled, - "auth.mfa.phone.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.phone.verify_enabled") - ? mfa.phone.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", - mfa.phone.verify_enabled, - "auth.mfa.phone.verify_enabled", - projectEnvValues, - ), - otp_length: remoteWins("auth.mfa.phone.otp_length") - ? mfa.phone.otp_length - : envOverrideUint( - "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", - "auth.mfa.phone.otp_length", - mfa.phone.otp_length, - projectEnvValues, - ), - template: remoteWins("auth.mfa.phone.template") - ? mfa.phone.template - : (envOverride("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", mfa.phone.template, projectEnvValues) ?? - mfa.phone.template), - max_frequency: remoteWins("auth.mfa.phone.max_frequency") - ? mfa.phone.max_frequency - : (envOverride( - "SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", - mfa.phone.max_frequency, - projectEnvValues, - ) ?? mfa.phone.max_frequency), - }, - web_authn: { - enroll_enabled: remoteWins("auth.mfa.web_authn.enroll_enabled") - ? mfa.web_authn.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", - mfa.web_authn.enroll_enabled, - "auth.mfa.web_authn.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.web_authn.verify_enabled") - ? mfa.web_authn.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", - mfa.web_authn.verify_enabled, - "auth.mfa.web_authn.verify_enabled", - projectEnvValues, - ), - }, - max_enrolled_factors: remoteWins("auth.mfa.max_enrolled_factors") - ? mfa.max_enrolled_factors - : envOverrideUint( - "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", - "auth.mfa.max_enrolled_factors", - mfa.max_enrolled_factors, - projectEnvValues, - ), - }; +/** The effective `auth.mfa`. */ +export function resolveAuthMfa(mfa: CliConfig["auth"]["mfa"]): CliConfig["auth"]["mfa"] { + return mfa; } -/** - * Resolves `auth.rate_limit.*`, all plain `uint`s with no presence gate: every - * `SUPABASE_AUTH_RATE_LIMIT_*` override applies unconditionally. - */ +/** The effective `auth.rate_limit`. */ export function resolveGotrueRateLimit( rateLimit: CliConfig["auth"]["rate_limit"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["rate_limit"] { - return { - anonymous_users: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", - "auth.rate_limit.anonymous_users", - rateLimit.anonymous_users, - projectEnvValues, - ), - token_refresh: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_REFRESH", - "auth.rate_limit.token_refresh", - rateLimit.token_refresh, - projectEnvValues, - ), - sign_in_sign_ups: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SIGN_IN_SIGN_UPS", - "auth.rate_limit.sign_in_sign_ups", - rateLimit.sign_in_sign_ups, - projectEnvValues, - ), - token_verifications: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_VERIFICATIONS", - "auth.rate_limit.token_verifications", - rateLimit.token_verifications, - projectEnvValues, - ), - email_sent: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT", - "auth.rate_limit.email_sent", - rateLimit.email_sent, - projectEnvValues, - ), - sms_sent: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", - "auth.rate_limit.sms_sent", - rateLimit.sms_sent, - projectEnvValues, - ), - web3: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_WEB3", - "auth.rate_limit.web3", - rateLimit.web3, - projectEnvValues, - ), - }; + return rateLimit; } -/** - * Resolves `auth.sessions.{timebox,inactivity_timeout}`. An env override can introduce a - * value even when `[auth.sessions]` was never in config.toml, since `config.auth.sessions` - * can be `undefined` here unlike other always-bound sections. - */ +/** The effective `auth.sessions`. */ export function resolveGotrueSessions( sessions: CliConfig["auth"]["sessions"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["sessions"] { - const timebox = envOverride( - "SUPABASE_AUTH_SESSIONS_TIMEBOX", - sessions?.timebox, - projectEnvValues, - ); - const inactivityTimeout = envOverride( - "SUPABASE_AUTH_SESSIONS_INACTIVITY_TIMEOUT", - sessions?.inactivity_timeout, - projectEnvValues, - ); - if (timebox === undefined && inactivityTimeout === undefined) return sessions; - return { timebox, inactivity_timeout: inactivityTimeout }; + return sessions; } /** - * Resolves `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields at - * all: presence and every field come from the raw TOML document. An absent section is never - * synthesized from an env override alone. + * Reads `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields: presence + * and every field come from the effective document. An absent section stays absent. */ export function resolveGotruePasskeyWebauthn( document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, ): { readonly passkeyEnabled: boolean | undefined; readonly webauthn: @@ -1674,496 +589,86 @@ export function resolveGotruePasskeyWebauthn( const webauthnDoc = asRecord(authDoc?.["webauthn"]); const passkeyEnabled = passkeyDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : undefined; - const rpOriginsOverride = - webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + ? rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled") : undefined; const webauthn = webauthnDoc !== undefined ? { - rpId: - envOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_ID", - typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : "", - projectEnvValues, - ) ?? "", - rpDisplayName: - envOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", - typeof webauthnDoc["rp_display_name"] === "string" - ? webauthnDoc["rp_display_name"] - : "", - projectEnvValues, - ) ?? "", - // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. - rpOrigins: (() => { - if (rpOriginsOverride !== undefined) return strToArr(rpOriginsOverride); - const raw = webauthnDoc["rp_origins"]; - if (Array.isArray(raw)) { - return raw.filter((item): item is string => typeof item === "string"); - } - return typeof raw === "string" ? strToArr(raw) : []; - })(), + rpId: asString(webauthnDoc["rp_id"]) ?? "", + rpDisplayName: asString(webauthnDoc["rp_display_name"]) ?? "", + rpOrigins: rawOrigins(webauthnDoc["rp_origins"]) ?? [], } : undefined; return { passkeyEnabled, webauthn }; } -/** Resolves `auth.web3.*.enabled`, both value-typed with no presence gate — overrides always apply. */ -export function resolveGotrueWeb3( - web3: CliConfig["auth"]["web3"], - projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["web3"] { - return { - solana: { - enabled: envOverrideBool( - "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", - web3.solana.enabled, - "auth.web3.solana.enabled", - projectEnvValues, - ), - }, - ethereum: { - enabled: envOverrideBool( - "SUPABASE_AUTH_WEB3_ETHEREUM_ENABLED", - web3.ethereum.enabled, - "auth.web3.ethereum.enabled", - projectEnvValues, - ), - }, - }; +/** A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. */ +function rawOrigins(raw: unknown): Array | undefined { + if (Array.isArray(raw)) return raw.filter((item): item is string => typeof item === "string"); + return typeof raw === "string" ? strToArr(raw) : undefined; } -/** Resolves `auth.oauth_server.*`, value-typed with no presence gate — overrides always apply. */ +/** The effective `auth.web3`. */ +export function resolveGotrueWeb3(web3: CliConfig["auth"]["web3"]): CliConfig["auth"]["web3"] { + return web3; +} + +/** The effective `auth.oauth_server`. */ export function resolveGotrueOAuthServer( oauthServer: CliConfig["auth"]["oauth_server"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["oauth_server"] { - return { - enabled: envOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", - oauthServer.enabled, - "auth.oauth_server.enabled", - projectEnvValues, - ), - authorization_url_path: - envOverride( - "SUPABASE_AUTH_OAUTH_SERVER_AUTHORIZATION_URL_PATH", - oauthServer.authorization_url_path, - projectEnvValues, - ) ?? oauthServer.authorization_url_path, - allow_dynamic_registration: envOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", - oauthServer.allow_dynamic_registration, - "auth.oauth_server.allow_dynamic_registration", - projectEnvValues, - ), - }; + return oauthServer; } -/** - * Resolves enabled `auth.third_party.` entries in a fixed order, forwarding only - * the enabled ones. `remoteOverrideKeys` lets a matched remote block's value win over a - * conflicting `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED`, since `envOverrideBool` would otherwise - * throw and abort the whole call. - */ +/** Lists enabled `auth.third_party.` entries in a fixed order, with their required fields. */ export function resolveThirdPartyProviders( thirdParty: CliConfig["auth"]["third_party"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const resolved: Array = []; - if ( - remoteWins("auth.third_party.firebase.enabled") - ? thirdParty.firebase.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - thirdParty.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "firebase", - requiredField: - (remoteWins("auth.third_party.firebase.project_id") - ? thirdParty.firebase.project_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - thirdParty.firebase.project_id, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.firebase.enabled) { + resolved.push({ provider: "firebase", requiredField: thirdParty.firebase.project_id ?? "" }); } - if ( - remoteWins("auth.third_party.auth0.enabled") - ? thirdParty.auth0.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - thirdParty.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "auth0", - requiredField: - (remoteWins("auth.third_party.auth0.tenant") - ? thirdParty.auth0.tenant - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - thirdParty.auth0.tenant, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.auth0.enabled) { + resolved.push({ provider: "auth0", requiredField: thirdParty.auth0.tenant ?? "" }); } - if ( - remoteWins("auth.third_party.aws_cognito.enabled") - ? thirdParty.aws_cognito.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - thirdParty.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ) - ) { + if (thirdParty.aws_cognito.enabled) { resolved.push({ provider: "cognito", - requiredField: - (remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? thirdParty.aws_cognito.user_pool_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - thirdParty.aws_cognito.user_pool_id, - projectEnvValues, - )) ?? "", - cognitoUserPoolRegion: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? thirdParty.aws_cognito.user_pool_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - thirdParty.aws_cognito.user_pool_region, - projectEnvValues, - ), + requiredField: thirdParty.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: thirdParty.aws_cognito.user_pool_region, }); } - if ( - remoteWins("auth.third_party.clerk.enabled") - ? thirdParty.clerk.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - thirdParty.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "clerk", - requiredField: - (remoteWins("auth.third_party.clerk.domain") - ? thirdParty.clerk.domain - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - thirdParty.clerk.domain, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.clerk.enabled) { + resolved.push({ provider: "clerk", requiredField: thirdParty.clerk.domain ?? "" }); } - if ( - remoteWins("auth.third_party.workos.enabled") - ? thirdParty.workos.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - thirdParty.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "workos", - requiredField: - (remoteWins("auth.third_party.workos.issuer_url") - ? thirdParty.workos.issuer_url - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - thirdParty.workos.issuer_url, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.workos.enabled) { + resolved.push({ provider: "workos", requiredField: thirdParty.workos.issuer_url ?? "" }); } return resolved; } -/** Fixed SMS provider priority — validation stops at the first enabled provider. */ -const SMS_PROVIDER_ORDER = [ - "twilio", - "twilio_verify", - "messagebird", - "textlocal", - "vonage", -] as const; - /** - * Resolves `auth.sms`'s full field set so `SUPABASE_AUTH_SMS_*` overrides reach GoTrue's env, - * not just validation. Presence-gated per provider except `twilio`, whose default template - * always includes an uncommented `[auth.sms.twilio]` table; the 4 top-level scalars have no - * gate for the same reason. `test_otp` (a map) is left unresolved: it has no env var. + * The effective `auth.sms`. Phone signup is never enabled when no provider is configured to + * deliver an OTP, and twilio's two required ids default to `""`. */ -export function resolveAuthSms( - authDocument: Readonly> | undefined, - sms: CliConfig["auth"]["sms"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed - * value belong in this set so a matched remote block's value wins instead of aborting on an - * unrelated bad env var. Reachable from the `db diff --linked`/`db pull` shadow path via - * `validateAuthSmsProviders`, not just this function's direct callers. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["sms"] { - const smsDoc = asRecord(authDocument?.["sms"]); - const remoteWins = makeRemoteWins(remoteOverrideKeys); - - function providerPresent(providerName: (typeof SMS_PROVIDER_ORDER)[number]): boolean { - // `twilio` is always considered present — see this function's doc comment. - if (providerName === "twilio") return true; - return smsDoc !== undefined && asRecord(smsDoc[providerName]) !== undefined; - } - - function resolveEnabled( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - configured: boolean, - ): boolean { - if (remoteWins(`auth.sms.${providerName}.enabled`)) return configured; - if (!providerPresent(providerName)) return configured; - return envOverrideBool( - `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_ENABLED`, - configured, - `auth.sms.${providerName}.enabled`, - projectEnvValues, - ); - } - - // `remoteOverrideKey` is explicit (not reconstructed from `providerName`/`field`) since - // `field`'s shape differs per provider, and not every combination is a real config key. - function resolveField( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - field: string, - remoteOverrideKey: RemoteOverridableKey, - configured: string | undefined, - ): string | undefined { - if (remoteWins(remoteOverrideKey)) return configured; - if (!providerPresent(providerName)) return configured; - return envOverride( - `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_${field.toUpperCase()}`, - configured, - projectEnvValues, - ); - } - - /** Resolves a provider's Secret-typed field, gated the same way `auth.email.smtp.pass` is. */ - function resolveSecretField( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - field: string, - remoteOverrideKey: RemoteOverridableKey, - configured: string | undefined, - ): string | undefined { - return remoteWins(remoteOverrideKey) - ? decryptAuthSecret(configured, projectEnvValues) - : decryptAuthSecret( - resolveField(providerName, field, remoteOverrideKey, configured), - projectEnvValues, - ); - } - - const twilioEnabled = resolveEnabled("twilio", sms.twilio.enabled); - const twilioVerifyEnabled = resolveEnabled("twilio_verify", sms.twilio_verify.enabled); - const messagebirdEnabled = resolveEnabled("messagebird", sms.messagebird.enabled); - const textlocalEnabled = resolveEnabled("textlocal", sms.textlocal.enabled); - const vonageEnabled = resolveEnabled("vonage", sms.vonage.enabled); +export function resolveAuthSms(sms: CliConfig["auth"]["sms"]): CliConfig["auth"]["sms"] { const anyProviderEnabled = - twilioEnabled || twilioVerifyEnabled || messagebirdEnabled || textlocalEnabled || vonageEnabled; - const enableSignupConfigured = remoteWins("auth.sms.enable_signup") - ? sms.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ); - + sms.twilio.enabled || + sms.twilio_verify.enabled || + sms.messagebird.enabled || + sms.textlocal.enabled || + sms.vonage.enabled; return { ...sms, - // Phone signup is never enabled when no provider is configured to deliver an OTP. - enable_signup: anyProviderEnabled ? enableSignupConfigured : false, - enable_confirmations: remoteWins("auth.sms.enable_confirmations") - ? sms.enable_confirmations - : envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", - sms.enable_confirmations, - "auth.sms.enable_confirmations", - projectEnvValues, - ), - template: remoteWins("auth.sms.template") - ? sms.template - : (envOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? sms.template), - max_frequency: remoteWins("auth.sms.max_frequency") - ? sms.max_frequency - : (envOverride("SUPABASE_AUTH_SMS_MAX_FREQUENCY", sms.max_frequency, projectEnvValues) ?? - sms.max_frequency), + enable_signup: anyProviderEnabled ? sms.enable_signup : false, twilio: { - enabled: twilioEnabled, - account_sid: - resolveField( - "twilio", - "account_sid", - "auth.sms.twilio.account_sid", - sms.twilio.account_sid, - ) ?? "", - message_service_sid: - resolveField( - "twilio", - "message_service_sid", - "auth.sms.twilio.message_service_sid", - sms.twilio.message_service_sid, - ) ?? "", - auth_token: resolveSecretField( - "twilio", - "auth_token", - "auth.sms.twilio.auth_token", - sms.twilio.auth_token, - ), - }, - twilio_verify: { - enabled: twilioVerifyEnabled, - account_sid: resolveField( - "twilio_verify", - "account_sid", - "auth.sms.twilio_verify.account_sid", - sms.twilio_verify.account_sid, - ), - message_service_sid: resolveField( - "twilio_verify", - "message_service_sid", - "auth.sms.twilio_verify.message_service_sid", - sms.twilio_verify.message_service_sid, - ), - auth_token: resolveSecretField( - "twilio_verify", - "auth_token", - "auth.sms.twilio_verify.auth_token", - sms.twilio_verify.auth_token, - ), - }, - messagebird: { - enabled: messagebirdEnabled, - originator: resolveField( - "messagebird", - "originator", - "auth.sms.messagebird.originator", - sms.messagebird.originator, - ), - access_key: resolveSecretField( - "messagebird", - "access_key", - "auth.sms.messagebird.access_key", - sms.messagebird.access_key, - ), - }, - textlocal: { - enabled: textlocalEnabled, - sender: resolveField( - "textlocal", - "sender", - "auth.sms.textlocal.sender", - sms.textlocal.sender, - ), - api_key: resolveSecretField( - "textlocal", - "api_key", - "auth.sms.textlocal.api_key", - sms.textlocal.api_key, - ), - }, - vonage: { - enabled: vonageEnabled, - from: resolveField("vonage", "from", "auth.sms.vonage.from", sms.vonage.from), - api_key: resolveField("vonage", "api_key", "auth.sms.vonage.api_key", sms.vonage.api_key), - api_secret: resolveSecretField( - "vonage", - "api_secret", - "auth.sms.vonage.api_secret", - sms.vonage.api_secret, - ), + ...sms.twilio, + account_sid: sms.twilio.account_sid ?? "", + message_service_sid: sms.twilio.message_service_sid ?? "", }, }; } -/** - * Validates only the first enabled provider in {@link SMS_PROVIDER_ORDER}; a later - * enabled-but-incomplete provider is never checked. Runs against {@link resolveAuthSms}'s - * env-override-aware result. - */ -function validateAuthSmsProviders( - authDocument: Record | undefined, - sms: CliConfig["auth"]["sms"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); - - function requireField(provider: string, field: string, value: string | undefined): void { - if (value === undefined || value.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.sms.${provider}.${field}`, - ); - } - } - - if (resolved.twilio.enabled) { - requireField("twilio", "account_sid", resolved.twilio.account_sid); - requireField("twilio", "message_service_sid", resolved.twilio.message_service_sid); - requireField("twilio", "auth_token", resolved.twilio.auth_token); - return; - } - if (resolved.twilio_verify.enabled) { - requireField("twilio_verify", "account_sid", resolved.twilio_verify.account_sid); - requireField( - "twilio_verify", - "message_service_sid", - resolved.twilio_verify.message_service_sid, - ); - requireField("twilio_verify", "auth_token", resolved.twilio_verify.auth_token); - return; - } - if (resolved.messagebird.enabled) { - requireField("messagebird", "originator", resolved.messagebird.originator); - requireField("messagebird", "access_key", resolved.messagebird.access_key); - return; - } - if (resolved.textlocal.enabled) { - requireField("textlocal", "sender", resolved.textlocal.sender); - requireField("textlocal", "api_key", resolved.textlocal.api_key); - return; - } - if (resolved.vonage.enabled) { - requireField("vonage", "from", resolved.vonage.from); - requireField("vonage", "api_key", resolved.vonage.api_key); - requireField("vonage", "api_secret", resolved.vonage.api_secret); - return; - } -} - -/** Deleted external providers, warned on if still enabled, never validated. */ -const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); - /** Matches `GotrueExternalProviderInput`'s fields, kept separate to avoid a command-specific import. */ export interface ResolvedAuthExternalProvider { readonly enabled: boolean; @@ -2176,7 +681,7 @@ export interface ResolvedAuthExternalProvider { } /** - * Weakly coerces an unmodeled raw-document value (no `@supabase/config` schema, e.g. custom + * Weakly coerces an unmodeled document value (no `@supabase/config` schema, e.g. custom * `auth.external` providers, `auth.passkey`/`auth.webauthn`) to a bool, since an `env(VAR)` * substitution there skips normal type coercion and leaves a literal `"true"`/`"false"` * string. A number coerces via truthiness (`!= 0`); an unparsable string or any other type @@ -2186,519 +691,158 @@ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boole if (value === undefined) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; - if (typeof value === "string") { - const parsed = parseGoBool(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; + const raw = typeof value === "string" ? value : String(value); + const parsed = typeof value === "string" ? parseBoolLiteral(value) : undefined; + if (parsed === undefined) { + throw new CliConfigValueError({ + path: dottedFieldPath, + tier: "config", + message: `Invalid config for ${dottedFieldPath}: "${raw}" is not a valid boolean`, + }); } - throw new InvalidBoolEnvOverrideError(dottedFieldPath, String(value)); + return parsed; } -/** - * Comma-splits a string into an array (empty string → `[]`), the same rule config decode - * applies to a raw or `env(VAR)`-resolved value destined for an unmodeled `[]string` field - * (e.g. `auth.webauthn.rp_origins`). - */ -export function strToArr(value: string): Array { +/** Comma-splits a string, with the empty string as `[]`. */ +function strToArr(value: string): Array { return value.length === 0 ? [] : value.split(","); } /** - * Resolves `auth.external.` overrides, iterating the raw document's provider names + * Resolves `auth.external.` entries, iterating the effective document's provider names * (not just the schema's fixed ~19) since custom providers decode with no schema at all. * `apple` is always included, since the default config.toml template registers it - * uncommented. Hoisted so both {@link validateAuthExternalProviders} and `start.handler.ts`'s - * GoTrue env resolution share this same result. + * uncommented. */ export function resolveAuthExternalProviders( authDocument: Readonly> | undefined, external: CliConfig["auth"]["external"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed - * value belong in this set so a matched remote block's value wins instead of aborting on an - * unrelated bad env var. Provider names are dynamic, so these keys are tracked via - * `applyRemoteOverride` rather than a fixed `ENV_OVERRIDABLE_KEYS` entry. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): Record { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const externalDoc = asRecord(authDocument?.["external"]); const result: Record = {}; const decodedProviders = new Map(Object.entries(external)); - // Iterates the raw document's keys (not `Object.entries(external)`) to catch custom - // provider names; `apple` is unioned in since the default template always registers it. const providerNames = new Set([...Object.keys(externalDoc ?? {}), "apple"]); for (const name of providerNames) { - if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; - const envPrefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; const provider = decodedProviders.get(name); const rawProvider = provider === undefined ? asRecord(externalDoc?.[name]) : undefined; if (provider === undefined && rawProvider === undefined) continue; - const configuredEnabled = - provider?.enabled ?? - rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`); - const configuredClientId = - provider?.client_id ?? - (typeof rawProvider?.["client_id"] === "string" ? rawProvider["client_id"] : undefined); - const configuredSecret = - provider?.secret ?? - (typeof rawProvider?.["secret"] === "string" ? rawProvider["secret"] : undefined); - const configuredUrl = - provider?.url ?? (typeof rawProvider?.["url"] === "string" ? rawProvider["url"] : undefined); - const configuredRedirectUri = - provider?.redirect_uri ?? - (typeof rawProvider?.["redirect_uri"] === "string" ? rawProvider["redirect_uri"] : undefined); - const configuredSkipNonceCheck = - provider?.skip_nonce_check ?? - rawUnmodeledBool(rawProvider?.["skip_nonce_check"], `auth.external.${name}.skip_nonce_check`); - const configuredEmailOptional = - provider?.email_optional ?? - rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`); - result[name] = { - enabled: remoteWins(`auth.external.${name}.enabled`) - ? configuredEnabled - : envOverrideBool( - `${envPrefix}_ENABLED`, - configuredEnabled, - `auth.external.${name}.enabled`, - projectEnvValues, - ), - clientId: - (remoteWins(`auth.external.${name}.client_id`) - ? configuredClientId - : envOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? "", - secret: remoteWins(`auth.external.${name}.secret`) - ? decryptAuthSecret(configuredSecret, projectEnvValues) - : decryptAuthSecret( - envOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), - projectEnvValues, - ), - url: - (remoteWins(`auth.external.${name}.url`) - ? configuredUrl - : envOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", - redirectUri: remoteWins(`auth.external.${name}.redirect_uri`) - ? configuredRedirectUri - : envOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), - skipNonceCheck: remoteWins(`auth.external.${name}.skip_nonce_check`) - ? configuredSkipNonceCheck - : envOverrideBool( - `${envPrefix}_SKIP_NONCE_CHECK`, - configuredSkipNonceCheck, - `auth.external.${name}.skip_nonce_check`, - projectEnvValues, - ), - emailOptional: remoteWins(`auth.external.${name}.email_optional`) - ? configuredEmailOptional - : envOverrideBool( - `${envPrefix}_EMAIL_OPTIONAL`, - configuredEmailOptional, - `auth.external.${name}.email_optional`, - projectEnvValues, - ), + enabled: + provider?.enabled ?? + rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`), + clientId: provider?.client_id ?? asString(rawProvider?.["client_id"]) ?? "", + secret: provider?.secret ?? asString(rawProvider?.["secret"]), + url: provider?.url ?? asString(rawProvider?.["url"]) ?? "", + redirectUri: provider?.redirect_uri ?? asString(rawProvider?.["redirect_uri"]), + skipNonceCheck: + provider?.skip_nonce_check ?? + rawUnmodeledBool( + rawProvider?.["skip_nonce_check"], + `auth.external.${name}.skip_nonce_check`, + ), + emailOptional: + provider?.email_optional ?? + rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`), }; } return result; } /** - * Validates required fields for every enabled `auth.external.` provider, including - * custom names `@supabase/config`'s schema silently drops at decode time. Runs against - * {@link resolveAuthExternalProviders}'s env-override-aware result, since the schema's own - * `requiredWhenEnabled` check only sees the pre-override, known-providers-only value. - */ -function validateAuthExternalProviders( - authDocument: Record | undefined, - external: CliConfig["auth"]["external"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthExternalProviders( - authDocument, - external, - projectEnvValues, - remoteOverrideKeys, - ); - for (const [name, provider] of Object.entries(resolved)) { - if (!provider.enabled) continue; - if (provider.clientId.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.client_id`, - ); - } - if ( - name !== "apple" && - name !== "google" && - (provider.secret === undefined || provider.secret.length === 0) - ) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.secret`, - ); - } - } -} - -/** - * @throws when `project_id` (post-override, post-workdir-basename-fallback) is an explicit - * empty string. Checked first: the sanitized workdir basename is merged in as a default - * before `config.toml`, so `project_id` is never empty by the time validation runs — a - * workdir whose basename sanitizes to `""` fails even with no `project_id` key at all. + * @throws when `project_id` is an explicit empty string. Checked first: the sanitized workdir + * basename is the default, so a workdir whose basename sanitizes to `""` fails even with no + * `project_id` key at all. * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override - * doesn't parse as a valid bool. * @throws when a configured `api.tls` cert/key file can't be read — see * {@link readApiTlsFiles}. * @throws when `auth.signing_keys_path` is set, auth is enabled, and the file is missing, * malformed, or its first key uses an unsupported algorithm — see - * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricGoJwt}. + * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricLocalJwt}. * @throws when an email template's `content` is present without `content_path`, or a * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. - * @throws {InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` doesn't - * parse as one of its accepted values. * @throws {ConfigValidateError} for every other validation branch, deferred to a single call * to {@link validateResolvedConfig} at the end of this function. */ -export function resolveLocalConfigValues( +export const resolveLocalConfigValues = Effect.fnUntraced(function* ( config: CliConfig, hostname: string, workdir: string, - projectEnvValues?: Readonly>, /** - * The raw, pre-schema-default TOML document `config` was decoded from, letting checks that - * hinge on section presence (not the always-defaulted decoded value) inspect the file - * directly. `undefined` callers simply skip those checks. + * The effective document `config` was decoded from, for checks that hinge on section presence + * (not the always-defaulted decoded value). `undefined` callers skip those checks. */ document?: Readonly>, - /** - * Config keys a matched `[remotes.]` block set at override tier. Every field whose - * `envOverride*` call (or a downstream {@link validateResolvedConfig} check) can throw on a - * malformed value belongs in this set — this function returns its whole object or throws, so - * an unconditional throw anywhere aborts the whole call, even for a field not part of the - * returned `LocalConfigValues`. Defaults to empty for callers with no `[remotes.]` block. - */ - remoteOverrideKeys: ReadonlySet = new Set(), - /** - * Default `project_id` supplied by `--project-ref`/the linked project ref before the - * basename fallback applies. `undefined` for `status`/`stop`, which have no such flag. - */ - projectIdFallback?: string, -): LocalConfigValues { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - // Checked first, before every other field: `config.project_id` is `undefined` only when the - // key is absent, in which case the sanitized workdir basename (or - // `projectIdFallback`) applies instead of a file value. - // - // Not gated by `remoteWins("project_id")` like the fields below: `envOverride` never turns - // an already non-empty remote-merged value empty or vice versa, so gating here couldn't - // change the accept/reject outcome. - const resolvedProjectId = envOverride( - "SUPABASE_PROJECT_ID", - config.project_id ?? - (projectIdFallback !== undefined && projectIdFallback.length > 0 - ? projectIdFallback - : sanitizeProjectId(basename(workdir))), - projectEnvValues, - ); +) { + const resolvedProjectId = config.project_id ?? ""; - const apiTlsEnabled = remoteWins("api.tls.enabled") - ? config.api.tls.enabled - : envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ); - // TLS cert/key validation only runs while `api.enabled`, so this gates on the post-override - // value, not raw `config.api.enabled`. - const apiEnabled = remoteWins("api.enabled") - ? config.api.enabled - : envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues); - const apiTlsCertPath = remoteWins("api.tls.cert_path") - ? config.api.tls.cert_path - : envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); - const apiTlsKeyPath = remoteWins("api.tls.key_path") - ? config.api.tls.key_path - : envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); + const apiTlsEnabled = config.api.tls.enabled; + const apiEnabled = config.api.enabled; + const apiTlsCertPath = config.api.tls.cert_path; + const apiTlsKeyPath = config.api.tls.key_path; if (apiEnabled && apiTlsEnabled) { - readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + yield* readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } - // `api.port === 0` is rejected only when `api.enabled`, unlike `db.port` below. Resolved once - // so the check and the URL derivation below share the same overridden value. - const apiPort = remoteWins("api.port") - ? config.api.port - : envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); - const apiExternalUrl = resolveApiExternalUrl( - { - external_url: remoteWins("api.external_url") - ? config.api.external_url - : envOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), - port: apiPort, - tls: { enabled: apiTlsEnabled }, - }, - hostname, + const apiPort = config.api.port; + const apiExternalUrl = yield* attempt(() => + resolveApiExternalUrl( + { + external_url: config.api.external_url, + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, + ), ); - // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no `enabled` gate — - // it's unconditionally required, and a decoded `0` fails validation. - const dbPort = remoteWins("db.port") - ? config.db.port - : envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); - // Validate-only: the shadow's own resolved `majorVersion` comes from - // `resolveDbBootstrapConfig`, which gates it separately. - const majorVersion = remoteWins("db.major_version") - ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // Validate-only: the actual resolved settings `start` needs are recomputed at their own call - // site. - resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); - // Validate-only: `start` doesn't otherwise consume `db.network_restrictions.enabled` (only - // `config push` does). - if (!remoteWins("db.network_restrictions.enabled")) { - envOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ); + const dbPort = config.db.port; + const majorVersion = config.db.major_version; + // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the document. + // The resolved value is written verbatim into `/etc/postgresql-custom/pgsodium_root.key`. + const rawRootKey = asRecord(document?.["db"])?.["root_key"]; + if (rawRootKey !== undefined && typeof rawRootKey !== "string") { + return yield* new ConfigValidateError("failed to parse config:\ndb.root_key: expected a table"); } - // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the raw - // pre-schema document. The resolved, decrypted-if-`encrypted:` value is written verbatim into - // `/etc/postgresql-custom/pgsodium_root.key` on every start. - const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; - if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { - throw new ConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", - ); - } - const rawRootKey = remoteWins("db.root_key") - ? rawRootKeyValue - : envOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); const rootKey = - rawRootKey === undefined || rawRootKey.length === 0 - ? POSTGRES_DEFAULT_ROOT_KEY - : (decryptAuthSecret(rawRootKey, projectEnvValues) ?? POSTGRES_DEFAULT_ROOT_KEY); + rawRootKey === undefined || rawRootKey.length === 0 ? POSTGRES_DEFAULT_ROOT_KEY : rawRootKey; const storageBucketNames = config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; - // `studio.port === 0` is rejected only when `studio.enabled`, same enabled-gated pattern as - // `api.port` above. - const studioEnabled = remoteWins("studio.enabled") - ? config.studio.enabled - : envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const studioPort = remoteWins("studio.port") - ? config.studio.port - : envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues); - // `envOverride` itself never throws, but `studio.api_url` feeds `validateResolvedConfig`'s - // URL-parse check below, which does throw on a malformed URL, so this is gated the same way - // as `studio.enabled`/`studio.port` above. - const studioApiUrl = remoteWins("studio.api_url") - ? config.studio.api_url - : (envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url); - // `local_smtp.port === 0` is rejected only when `local_smtp.enabled`; the deprecated - // `inbucket.enabled` alias is the same underlying flag, not a second one. - const mailpitEnabled = remoteWins("local_smtp.enabled") - ? config.local_smtp.enabled - : envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const mailpitPort = remoteWins("local_smtp.port") - ? config.local_smtp.port - : envOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ); - const jwtSecret = resolveJwtSecret( - decryptAuthSecret( - remoteWins("auth.jwt_secret") - ? config.auth.jwt_secret - : envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const studioEnabled = config.studio.enabled; + const studioPort = config.studio.port; + const studioApiUrl = config.studio.api_url; + const mailpitEnabled = config.local_smtp.enabled; + const mailpitPort = config.local_smtp.port; + const jwtSecret = yield* attempt(() => resolveJwtSecret(config.auth.jwt_secret)); + const signingKeysPath = config.auth.signing_keys_path; // The signing-keys file read only runs when auth is enabled, so a disabled auth section // never opens/parses `signing_keys_path`, even a stale or missing one. JWT-secret validation // and anon/service_role key generation run unconditionally either way. - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - // `@supabase/config`'s schema only defaults `site_url` when the key is absent, so an - // explicit `site_url = ""` decodes as `""` with no schema-level error, same gap as - // `db.port === 0` above. - const siteUrl = remoteWins("auth.site_url") - ? config.auth.site_url - : (envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? - config.auth.site_url); - const jwtIssuer = remoteWins("auth.jwt_issuer") - ? config.auth.jwt_issuer - : envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); - const jwtExpiry = remoteWins("auth.jwt_expiry") - ? config.auth.jwt_expiry - : envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ); - const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") - ? undefined - : envOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); - const additionalRedirectUrls = - additionalRedirectUrlsOverride !== undefined - ? additionalRedirectUrlsOverride.split(",") - : config.auth.additional_redirect_urls; - const enableSignup = remoteWins("auth.enable_signup") - ? config.auth.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ); - const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") - ? config.auth.enable_anonymous_sign_ins - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ); - const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") - ? config.auth.enable_refresh_token_rotation - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ); - const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") - ? config.auth.refresh_token_reuse_interval - : envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ); - const enableManualLinking = remoteWins("auth.enable_manual_linking") - ? config.auth.enable_manual_linking - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ); - const minimumPasswordLength = remoteWins("auth.minimum_password_length") - ? config.auth.minimum_password_length - : envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ); - const passwordRequirements = remoteWins("auth.password_requirements") - ? config.auth.password_requirements - : envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues); + const authEnabled = config.auth.enabled; + const siteUrl = config.auth.site_url; const authDocument = asRecord(document?.["auth"]); - const captchaInput = resolveAuthCaptcha( - authDocument, - config.auth.captcha, - projectEnvValues, - remoteOverrideKeys, - ); - // Reuses {@link resolveConfiguredSigningKeys}, which already gates the file read on - // `authEnabled` internally, rather than duplicating that gate here: a disabled-auth config - // with a configured path must still sign asymmetrically with the default key, not fall back - // to symmetric HS256. + const captchaInput = resolveAuthCaptcha(config.auth.captcha); + // A disabled-auth config with a configured path must still sign asymmetrically with the + // default key, not fall back to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (resolveConfiguredSigningKeys(config, workdir, projectEnvValues, remoteOverrideKeys) ?? [ - DEFAULT_SIGNING_KEY, - ])[0] + ? ((yield* resolveConfiguredSigningKeys(config, workdir)) ?? [DEFAULT_SIGNING_KEY])[0] : undefined; // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party // validation needs; the checks themselves run once, later, in the single // `validateResolvedConfig` call below (sms/external run separately after it). let authInput: AuthInput | undefined; if (authEnabled) { - // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all, so they're - // read from the raw, post-`env()`-interpolation TOML document instead of the decoded - // `CliConfig`. `authDocument` is `undefined` when a caller hasn't threaded `document` - // through yet, in which case these presence-based checks are simply skipped. const passkeyDoc = asRecord(authDocument?.["passkey"]); const webauthnDoc = asRecord(authDocument?.["webauthn"]); - // Gated on the raw section already being present: only keys already present in the merged - // config are env-bindable, so an absent `[auth.passkey]`/`[auth.webauthn]` section is never - // synthesized from an env override alone. - const passkeyEnabled = remoteWins("auth.passkey.enabled") - ? rawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") - : passkeyDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : false; - const configuredRpId = - typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; - const rpId = remoteWins("auth.webauthn.rp_id") - ? configuredRpId - : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) - : undefined; - const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") - ? undefined - : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) - : undefined; - // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. - const rawRpOrigins = webauthnDoc?.["rp_origins"]; - const rpOrigins = - rpOriginsOverride !== undefined - ? strToArr(rpOriginsOverride) - : Array.isArray(rawRpOrigins) - ? rawRpOrigins - : typeof rawRpOrigins === "string" - ? strToArr(rawRpOrigins) - : undefined; + const passkeyEnabled = + passkeyDoc !== undefined && + (yield* attempt(() => rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"))); + const rpId = asString(webauthnDoc?.["rp_id"]); + const rpOrigins = rawOrigins(webauthnDoc?.["rp_origins"]); const passkey: PasskeyInput | undefined = passkeyEnabled ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } : undefined; - // Only enabled hooks are forwarded, in {@link HOOK_TYPE_ORDER}. - const resolvedHooks = resolveAuthHooks( - authDocument, - config.auth.hook, - projectEnvValues, - remoteOverrideKeys, - ); + const resolvedHooks = resolveAuthHooks(config.auth.hook); const hooks: Array = HOOK_TYPE_ORDER.filter( (hookType) => resolvedHooks[HOOK_TYPE_TO_CAMEL[hookType]].enabled, ).map((hookType) => { @@ -2706,7 +850,7 @@ export function resolveLocalConfigValues( return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; }); - const resolvedMfa = resolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); + const resolvedMfa = config.auth.mfa; const mfa: ReadonlyArray = [ { label: "totp", @@ -2725,12 +869,9 @@ export function resolveLocalConfigValues( }, ]; - readAuthEmailTemplateContent( - resolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), - workdir, - ); + yield* readAuthEmailTemplateContent(resolveAuthEmail(config.auth.email, authDocument), workdir); - const resolvedSmtp = resolveAuthEmailSmtp(authDocument, projectEnvValues, remoteOverrideKeys); + const resolvedSmtp = resolveAuthEmailSmtp(authDocument); const smtp: SmtpInput | undefined = resolvedSmtp === undefined ? undefined @@ -2743,12 +884,6 @@ export function resolveLocalConfigValues( adminEmail: resolvedSmtp.adminEmail, }; - const thirdParty = resolveThirdPartyProviders( - config.auth.third_party, - projectEnvValues, - remoteOverrideKeys, - ); - authInput = { siteUrl: siteUrl ?? "", captcha: captchaInput, @@ -2756,84 +891,32 @@ export function resolveLocalConfigValues( hooks, mfa, smtp, - thirdParty, + thirdParty: yield* attempt(() => resolveThirdPartyProviders(config.auth.third_party)), }; } const functionSlugs = Object.keys(config.functions); - // `edge_runtime.deno_version` is checked unconditionally, with no `edge_runtime.enabled` - // gate, unlike `studio.port`/`local_smtp.port` above. - const denoVersion = remoteWins("edge_runtime.deno_version") - ? config.edge_runtime.deno_version - : envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - - // When `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP fields are - // required. Backend-enum validation is covered at decode time for the config.toml-sourced - // value by `@supabase/config`'s schema, but not for the `SUPABASE_ANALYTICS_BACKEND` - // env-override path — see {@link envOverrideAnalyticsBackend} for that case. - const analyticsEnabled = remoteWins("analytics.enabled") - ? config.analytics.enabled - : envOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", - projectEnvValues, - ); - const analyticsBackend = envOverrideAnalyticsBackend( - config.analytics.backend, - projectEnvValues, - remoteWins("analytics.backend"), + const denoVersion = config.edge_runtime.deno_version; + + const analyticsEnabled = config.analytics.enabled; + const analyticsBackend = yield* attempt(() => + narrowConfigEnum("analytics.backend", config.analytics.backend, ["postgres", "bigquery"]), ); - const gcpProjectId = remoteWins("analytics.gcp_project_id") - ? config.analytics.gcp_project_id - : envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - config.analytics.gcp_project_id, - projectEnvValues, - ); - const gcpProjectNumber = remoteWins("analytics.gcp_project_number") - ? config.analytics.gcp_project_number - : envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - config.analytics.gcp_project_number, - projectEnvValues, - ); - const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") - ? config.analytics.gcp_jwt_path - : envOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - config.analytics.gcp_jwt_path, - projectEnvValues, - ); + const gcpProjectId = config.analytics.gcp_project_id; + const gcpProjectNumber = config.analytics.gcp_project_number; + const gcpJwtPath = config.analytics.gcp_jwt_path; // The webhooks check isn't "the user disabled a feature": an omitted `enabled` key in a // present `[experimental.webhooks]` section is rejected too — the section exists only so it - // can be turned on, never explicitly off. This hinges on TOML-section presence, which the + // can be turned on, never explicitly off. This hinges on section presence, which the // schema's decode-time default erases (`experimental.webhooks` always decodes to - // `{ enabled: false }` even when absent), so this reads the raw document instead. + // `{ enabled: false }` even when absent), so this reads the document instead. const experimentalDocument = asRecord(document?.["experimental"]); const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; - // A malformed JSON override needs no separate error path: it flows through unchanged and - // `validateResolvedConfig`'s existing JSON-validity check reports it. - const webhooksEnabled = remoteWins("experimental.webhooks.enabled") - ? config.experimental.webhooks?.enabled === true - : envOverrideBool( - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - config.experimental.webhooks?.enabled === true, - "experimental.webhooks.enabled", - projectEnvValues, - ); - const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") - ? (config.experimental.pgdelta?.format_options ?? "") - : (envOverride( - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - config.experimental.pgdelta?.format_options, - projectEnvValues, - ) ?? ""); + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; // Every pure validation check runs in one place, here, rather than interleaved with this - // function's 3 I/O reads (signing keys, api.tls cert/key, email content) at their original - // relative positions — a config broken in two independent ways can report a different one of - // those errors than if checks ran interleaved. + // function's 3 I/O reads (signing keys, api.tls cert/key, email content). const apiInput: ApiInput = { enabled: apiEnabled, port: apiPort, @@ -2872,30 +955,22 @@ export function resolveLocalConfigValues( analytics: analyticsInput, experimental: experimentalInput, }; - validateResolvedConfig(input); - // sms/external run after the shared validation call, since both need the env-override-aware - // resolved value rather than the raw decoded config it validates. + yield* attempt(() => validateResolvedConfig(input)); if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); - validateAuthExternalProviders( - authDocument, - config.auth.external, - projectEnvValues, - remoteOverrideKeys, + yield* attempt(() => validateAuthSmsProviders(resolveAuthSms(config.auth.sms))); + yield* attempt(() => + validateAuthExternalProviders( + resolveAuthExternalProviders(authDocument, config.auth.external), + ), ); } - // Decrypted like `auth.email.smtp.pass`/`auth.captcha.secret`. - const openaiApiKey = remoteWins("studio.openai_api_key") - ? decryptAuthSecret(config.studio.openai_api_key, projectEnvValues) - : decryptAuthSecret( - envOverride( - "SUPABASE_STUDIO_OPENAI_API_KEY", - config.studio.openai_api_key, - projectEnvValues, - ), - projectEnvValues, - ); + const anonKey = yield* attempt(() => + resolveSignedKey(config.auth.anon_key, jwtSecret, signingKey, "anon"), + ); + const serviceRoleKey = yield* attempt(() => + resolveSignedKey(config.auth.service_role_key, jwtSecret, signingKey, "service_role"), + ); return { apiUrl: apiExternalUrl, @@ -2903,18 +978,18 @@ export function resolveLocalConfigValues( dbPort, studioPort, rootKey, - openaiApiKey, + openaiApiKey: config.studio.openai_api_key, authSiteUrl: siteUrl, - authJwtIssuer: jwtIssuer, - authJwtExpiry: jwtExpiry, - authAdditionalRedirectUrls: additionalRedirectUrls, - authEnableSignup: enableSignup, - authEnableAnonymousSignIns: enableAnonymousSignIns, - authEnableRefreshTokenRotation: enableRefreshTokenRotation, - authRefreshTokenReuseInterval: refreshTokenReuseInterval, - authEnableManualLinking: enableManualLinking, - authMinimumPasswordLength: minimumPasswordLength, - authPasswordRequirements: passwordRequirements, + authJwtIssuer: config.auth.jwt_issuer, + authJwtExpiry: config.auth.jwt_expiry, + authAdditionalRedirectUrls: config.auth.additional_redirect_urls, + authEnableSignup: config.auth.enable_signup, + authEnableAnonymousSignIns: config.auth.enable_anonymous_sign_ins, + authEnableRefreshTokenRotation: config.auth.enable_refresh_token_rotation, + authRefreshTokenReuseInterval: config.auth.refresh_token_reuse_interval, + authEnableManualLinking: config.auth.enable_manual_linking, + authMinimumPasswordLength: config.auth.minimum_password_length, + authPasswordRequirements: config.auth.password_requirements, restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), @@ -2922,56 +997,11 @@ export function resolveLocalConfigValues( studioUrl: `http://${hostname}:${studioPort}`, mailpitUrl: `http://${hostname}:${mailpitPort}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, - // Decrypted like `anon_key`/`service_role_key` below. - publishableKey: resolveOpaqueKey( - remoteWins("auth.publishable_key") - ? decryptAuthSecret(config.auth.publishable_key, projectEnvValues) - : decryptAuthSecret( - envOverride( - "SUPABASE_AUTH_PUBLISHABLE_KEY", - config.auth.publishable_key, - projectEnvValues, - ), - projectEnvValues, - ), - defaultPublishableKey, - ), - secretKey: resolveOpaqueKey( - remoteWins("auth.secret_key") - ? decryptAuthSecret(config.auth.secret_key, projectEnvValues) - : decryptAuthSecret( - envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), - projectEnvValues, - ), - defaultSecretKey, - ), + publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), + secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), jwtSecret, - anonKey: resolveSignedKey( - decryptAuthSecret( - remoteWins("auth.anon_key") - ? config.auth.anon_key - : envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), - projectEnvValues, - ), - jwtSecret, - signingKey, - "anon", - ), - serviceRoleKey: resolveSignedKey( - decryptAuthSecret( - remoteWins("auth.service_role_key") - ? config.auth.service_role_key - : envOverride( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - config.auth.service_role_key, - projectEnvValues, - ), - projectEnvValues, - ), - jwtSecret, - signingKey, - "service_role", - ), + anonKey, + serviceRoleKey, storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, @@ -2983,171 +1013,44 @@ export function resolveLocalConfigValues( gcpJwtPath: gcpJwtPath ?? "", // Sanitized here, not in `input.projectId` above: `validateResolvedConfig`'s check is // presence-only and must see the raw value to reject an explicit `project_id = ""`. - projectId: sanitizeProjectId(resolvedProjectId ?? ""), + projectId: sanitizeProjectId(resolvedProjectId), edgeRuntimeDenoVersion: denoVersion, - }; -} + } satisfies LocalConfigValues; +}); /** Resolves local and remote signing keys for services that consume the local JWKS document. */ export const resolveLocalJwks = Effect.fnUntraced(function* ( config: CliConfig, workdir: string, jwtSecret: string, - projectEnvValues?: Readonly>, - remoteOverrideKeys: ReadonlySet = new Set(), ) { - const { issuerUrl, signingKeys, signingKeysPath } = yield* Effect.try({ - try: () => { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's - // only ever replaced by a configured `signing_keys_path` file, and only when that file is - // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS - // resolution always publishes either the file's keys or this default, never neither — - // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. - const signingKeys: ReadonlyArray = resolveConfiguredSigningKeys( - config, - workdir, - projectEnvValues, - remoteOverrideKeys, - ) ?? [DEFAULT_SIGNING_KEY]; - - // Built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's - // `tenant_region`) rather than the validation-only `ThirdPartyInput`, since - // {@link resolveThirdPartyIssuerUrl} needs the full set to build the issuer URL. - const thirdParty: ThirdPartyProvidersLike = { - firebase: { - enabled: remoteWins("auth.third_party.firebase.enabled") - ? config.auth.third_party.firebase.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: remoteWins("auth.third_party.firebase.project_id") - ? config.auth.third_party.firebase.project_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), - }, - auth0: { - enabled: remoteWins("auth.third_party.auth0.enabled") - ? config.auth.third_party.auth0.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: remoteWins("auth.third_party.auth0.tenant") - ? config.auth.third_party.auth0.tenant - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: remoteWins("auth.third_party.auth0.tenant_region") - ? config.auth.third_party.auth0.tenant_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), - }, - aws_cognito: { - enabled: remoteWins("auth.third_party.aws_cognito.enabled") - ? config.auth.third_party.aws_cognito.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? config.auth.third_party.aws_cognito.user_pool_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? config.auth.third_party.aws_cognito.user_pool_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), - }, - clerk: { - enabled: remoteWins("auth.third_party.clerk.enabled") - ? config.auth.third_party.clerk.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: remoteWins("auth.third_party.clerk.domain") - ? config.auth.third_party.clerk.domain - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), - }, - workos: { - enabled: remoteWins("auth.third_party.workos.enabled") - ? config.auth.third_party.workos.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: remoteWins("auth.third_party.workos.issuer_url") - ? config.auth.third_party.workos.issuer_url - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), - }, - }; - - // This function is called unconditionally, regardless of `auth.enabled`, but - // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only - // meaningful while auth is enabled — `resolveLocalConfigValues` already ran the equivalent - // check in that case. When auth is disabled, that validation is (correctly) skipped, so this - // uses the unchecked, no-throw issuer-url builder instead. - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const issuerUrl = Option.fromNullishOr( - authEnabled + const signingKeysPath = config.auth.signing_keys_path; + // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's + // only ever replaced by a configured `signing_keys_path` file, and only when that file is + // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS + // resolution always publishes either the file's keys or this default, never neither — + // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. + const signingKeys: ReadonlyArray = (yield* resolveConfiguredSigningKeys( + config, + workdir, + )) ?? [DEFAULT_SIGNING_KEY]; + + // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only + // meaningful while auth is enabled; when auth is disabled they are skipped, so the + // unchecked, no-throw issuer-url builder applies instead. + const thirdParty = config.auth.third_party; + const issuerUrl = Option.fromNullishOr( + yield* Effect.try({ + try: () => + config.auth.enabled ? resolveThirdPartyIssuerUrl(thirdParty) : thirdPartyIssuerUrlUnchecked(thirdParty), - ).pipe(Option.filter((value) => value.length > 0)); - return { issuerUrl, signingKeys, signingKeysPath }; - }, - catch: (cause) => - cause instanceof ConfigValidateError - ? cause - : new ConfigValidateError(cause instanceof Error ? cause.message : String(cause)), - }); + catch: (cause) => + cause instanceof ConfigValidateError + ? cause + : new ConfigValidateError(cause instanceof Error ? cause.message : String(cause)), + }), + ).pipe(Option.filter((value) => value.length > 0)); const keys: unknown[] = []; if (Option.isSome(issuerUrl)) { keys.push( diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index 75fcdbb769..79353d4832 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -1,54 +1,52 @@ import { generateKeyPairSync } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; import { importJWK, jwtVerify } from "jose"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; -import { DEFAULT_SIGNING_KEY } from "./go-jwt.ts"; +import { CliConfigValueError } from "../config/cli-config.errors.ts"; +import { DEFAULT_SIGNING_KEY } from "./local-jwt.ts"; import { POSTGRES_DEFAULT_ROOT_KEY, - InvalidAnalyticsBackendEnvOverrideError, - InvalidBoolEnvOverrideError, - InvalidEdgeRuntimePolicyEnvOverrideError, InvalidJwtSecretError, - InvalidPoolModeEnvOverrideError, - InvalidPortEnvOverrideError, - InvalidRealtimeIpVersionEnvOverrideError, - InvalidSessionReplicationRoleEnvOverrideError, - envOverrideApiMaxRows, - envOverrideDefaultPoolSize, - envOverrideEdgeRuntimePolicy, - envOverrideMajorVersion, - envOverrideMaxClientConn, - envOverridePoolMode, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, + narrowConfigEnum, rawUnmodeledBool, resolveAuthCaptcha, resolveAuthEmail, - resolveAuthEmailSmtp, resolveAuthExternalProviders, - resolveAuthExternalUrl, resolveAuthHooks, - resolveAuthMfa, resolveAuthSms, - resolveConfiguredSigningKeys, resolveDbSettingsEnvOverrides, resolveLocalConfigValues, resolveLocalJwks, } from "./local-config-values.ts"; +const join = (...segments: ReadonlyArray) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(...segments); + }); + +const mkdirp = (directory: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(directory, { recursive: true }); + }); + +const writeFile = (file: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(file, contents); + }); + const runLocalJwks = (...args: Parameters) => - Effect.runPromise( - resolveLocalJwks(...args).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), - ), + resolveLocalJwks(...args).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), ); const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -65,4466 +63,1246 @@ function generateRsaJwk(): Record { return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; } -function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); -} - -describe("resolveLocalConfigValues", () => { - it("derives every URL from api.external_url when unset", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); - expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); - expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); - expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); - expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); - expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); - expect(values.studioUrl).toBe("http://127.0.0.1:54323"); - expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); +const writeSigningKeys = (workdir: string, jwks: ReadonlyArray>) => + Effect.gen(function* () { + const supabaseDir = yield* join(workdir, "supabase"); + yield* mkdirp(supabaseDir); + yield* writeFile(yield* join(supabaseDir, "signing_keys.json"), yield* encodeJson(jwks)); }); - it("uses https and the configured port when api.tls.enabled", () => { - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); +describe("resolveLocalConfigValues", () => { + it.live("derives every URL from api.external_url when unset", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - it("uses api.external_url verbatim when configured", () => { - const config = baseConfig({ api: { external_url: "https://example.test" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://example.test"); - expect(values.restUrl).toBe("https://example.test/rest/v1"); - }); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); + expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); + expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); + expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); + expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); + expect(values.studioUrl).toBe("http://127.0.0.1:54323"); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("brackets an IPv6 hostname when building host:port", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "::1", WORKDIR); - expect(values.apiUrl).toBe("http://[::1]:54321"); - }); + it.live("uses https and the configured port when api.tls.enabled", () => + Effect.gen(function* () { + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("builds the db URL with the hardcoded postgres password", () => { - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); - }); + it.live("uses api.external_url verbatim when configured", () => + Effect.gen(function* () { + const config = baseConfig({ api: { external_url: "https://example.test" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://example.test"); + expect(values.restUrl).toBe("https://example.test/rest/v1"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("falls back to the default JWT secret and opaque keys when unset", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); - expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); - expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); - }); + it.live("brackets an IPv6 hostname when building host:port", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "::1", WORKDIR); + expect(values.apiUrl).toBe("http://[::1]:54321"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("uses configured opaque keys verbatim when set", () => { - const config = baseConfig({ - auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.publishableKey).toBe("sb_publishable_custom"); - expect(values.secretKey).toBe("sb_secret_custom"); - }); + it.live("builds the db URL with the hardcoded postgres password", () => + Effect.gen(function* () { + const config = baseConfig({ db: { port: 54322 } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("signs the default anon/service_role JWTs from the resolved secret", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - const [, anonPayload] = values.anonKey.split("."); - const [, serviceRolePayload] = values.serviceRoleKey.split("."); - expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ - role: "anon", - }); - expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( - { role: "service_role" }, - ); - }); + it.live("falls back to the default JWT secret and opaque keys when unset", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); + expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); + expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("uses configured anon/service_role keys verbatim when set", () => { - const config = baseConfig({ - auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.anonKey).toBe("configured-anon"); - expect(values.serviceRoleKey).toBe("configured-service-role"); - }); + it.live("uses configured opaque keys verbatim when set", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("sb_publishable_custom"); + expect(values.secretKey).toBe("sb_secret_custom"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("signs anon/service_role JWTs from a configured jwt_secret", () => { - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("a".repeat(32)); - expect(values.anonKey).not.toBe(""); - }); + it.live("signs the default anon/service_role JWTs from the resolved secret", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const [, anonPayload] = values.anonKey.split("."); + const [, serviceRolePayload] = values.serviceRoleKey.split("."); + expect( + yield* decodeJson(Buffer.from(anonPayload ?? "", "base64url").toString()), + ).toMatchObject({ + role: "anon", + }); + expect( + yield* decodeJson(Buffer.from(serviceRolePayload ?? "", "base64url").toString()), + ).toMatchObject({ role: "service_role" }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("rejects a configured jwt_secret shorter than 16 characters", () => { - const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, - ); - }); + it.live("uses configured anon/service_role keys verbatim when set", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }).pipe(Effect.provide(BunServices.layer)), + ); - describe("encrypted auth secrets", () => { - // This ciphertext decrypts to "value" under the keypair below. - const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const VAULT_ENCRYPTED = - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + it.live("signs anon/service_role JWTs from a configured jwt_secret", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + expect(values.anonKey).not.toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - afterEach(() => { - delete process.env["DOTENV_PRIVATE_KEY"]; - }); + it.live("rejects a configured jwt_secret shorter than 16 characters", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); + expect( + yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)), + ).toBeInstanceOf(InvalidJwtSecretError); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("rejects an explicit empty project_id", () => + Effect.gen(function* () { + // An explicit `project_id = ""` overwrites the workdir-basename default with the literal + // empty string, unlike an absent key. + const config = baseConfig({ project_id: "" }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain("Missing required field in config: project_id"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, + it.live("hardcodes the local S3 credentials", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", ); - }); + expect(values.storageS3Region).toBe("local"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.publishableKey).toBe("value"); - }); + describe("non-auth port validation", () => { + // Unlike the malformed/out-of-range cases above, db.port=0 is a required-field failure with + // no `enabled` gate, unlike api.port/studio.port/local_smtp.port. + // api.enabled defaults to true, so this rejection applies without an explicit + // `api.enabled = true`. + it.live("rejects a configured api.port of 0 when api is enabled", () => + Effect.gen(function* () { + const config = baseConfig({ api: { port: 0 } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain("Missing required field in config: api.port"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("fails config loading for an encrypted: secret with no private key, matching Go", () => { - const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", - ); - }); + it.live("does not reject a zero api.port when api is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ api: { enabled: false, port: 0 } }); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("decrypts an encrypted: auth.email.smtp.pass, matching Go's Secret-typed Smtp.Pass field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const document = { auth: { email: { smtp: { enabled: true, pass: VAULT_ENCRYPTED } } } }; - const resolved = resolveAuthEmailSmtp(document.auth, undefined); - expect(resolved?.pass).toBe("value"); - }); + // studio.enabled defaults to true, so this rejection applies without an explicit + // `studio.enabled = true`. + it.live("rejects a configured studio.port of 0 when studio is enabled", () => + Effect.gen(function* () { + const config = baseConfig({ studio: { port: 0 } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain("Missing required field in config: studio.port"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("decrypts an encrypted: studio.openai_api_key, matching Go's Secret-typed OpenaiApiKey field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ studio: { openai_api_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.openaiApiKey).toBe("value"); - }); + it.live("does not reject a zero studio.port when studio is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ studio: { enabled: false, port: 0 } }); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, - }); - expect(values.secretKey).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; - }); - }); + it.live( + "rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", + () => + Effect.gen(function* () { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { - // An explicit `project_id = ""` overwrites the workdir-basename default with the literal - // empty string, unlike an absent key. - const config = baseConfig({ project_id: "" }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: project_id", + it.live("does not reject a malformed studio.api_url when studio is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it.live("does not throw for the default studio.api_url", () => + Effect.gen(function* () { + const config = baseConfig(); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { - // The workdir-basename default still applies with no `project_id` key present, so a workdir - // whose basename sanitizes to empty (e.g. `!!!`) still fails validation. - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( - "Missing required field in config: project_id", + // local_smtp.enabled defaults to true, so this rejection applies without an explicit + // `local_smtp.enabled = true`. + it.live("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => + Effect.gen(function* () { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR))).message, + ).toContain("Missing required field in config: local_smtp.port"); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); + it.live("does not reject a zero local_smtp.port when local_smtp is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); }); - it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { - const config = baseConfig({ project_id: "" }); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); - }); + describe("db.root_key (unmodeled raw-document field)", () => { + it.live("falls back to the default root key when absent", () => + Effect.gen(function* () { + const config = baseConfig(); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.rootKey).toBe(POSTGRES_DEFAULT_ROOT_KEY); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("hardcodes the Go-parity local S3 credentials", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); - expect(values.storageS3SecretAccessKey).toBe( - "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + it.live("uses a configured string root_key verbatim", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { db: { root_key: "custom-root-key" } }; + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document); + expect(values.rootKey).toBe("custom-root-key"); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.storageS3Region).toBe("local"); - }); - describe("SUPABASE_AUTH_* env overrides", () => { - const tempRoot = useTempWorkdir("supabase-signing-keys-env-override-test-"); + it.live("rejects a non-string root_key (e.g. a bare TOML integer)", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { db: { root_key: 12345 } }; + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document))) + .message, + ).toContain("failed to parse config:\ndb.root_key: expected a table"); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); - const ENV_KEYS = [ - "SUPABASE_AUTH_JWT_SECRET", - "SUPABASE_AUTH_PUBLISHABLE_KEY", - "SUPABASE_AUTH_SECRET_KEY", - "SUPABASE_AUTH_ANON_KEY", - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - ] as const; + // storage.buckets validation lives entirely in config-validate.unit.test.ts; this file has no + // bucket-related mechanics of its own. - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - }); + describe("experimental.* (experimental.validate())", () => { + // Exercises this function's own fallback when no `document` (5th param) is supplied; the + // required-field/enabled checks themselves live in config-validate.unit.test.ts. + it.live( + "does not throw a present [experimental.webhooks] section without enabled when no document is provided", + () => + Effect.gen(function* () { + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); - it("overrides jwt_secret even when config.toml sets one", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("b".repeat(32)); + describe("resolveAuthCaptcha", () => { + it("returns undefined when captcha is not configured", () => { + expect(resolveAuthCaptcha(undefined)).toBeUndefined(); }); + }); - it("overrides publishable_key/secret_key", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; + describe("resolveAuthEmail", () => { + it("keeps an explicit empty subject present in the raw document, not omitted", () => { const config = baseConfig({ - auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.publishableKey).toBe("env-publishable"); - expect(values.secretKey).toBe("env-secret"); + const authDocument = { email: { template: { confirmation: { subject: "" } } } }; + const resolved = resolveAuthEmail(config.auth.email, authDocument); + expect(resolved.template["confirmation"]?.subject).toBe(""); }); - it("overrides anon_key/service_role_key", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; + it("omits the subject when the key is absent from the raw document", () => { const config = baseConfig({ - auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + auth: { email: { template: { confirmation: { content_path: "x" } } } }, }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.anonKey).toBe("env-anon"); - expect(values.serviceRoleKey).toBe("env-service-role"); - }); - - it("treats an empty env var as unset, matching Viper's default", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("a".repeat(32)); - }); - - it("still applies the short-secret validation to an env-provided jwt_secret", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, - ); + const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; + const resolved = resolveAuthEmail(config.auth.email, authDocument); + expect(resolved.template["confirmation"]?.subject).toBeUndefined(); }); + }); - it("overrides signing_keys_path even when config.toml doesn't set one", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - }); + describe("resolveAuthHooks", () => { + const baseHook = { enabled: false, uri: "", secrets: "" }; + const allHooks = { + mfa_verification_attempt: baseHook, + password_verification_attempt: baseHook, + custom_access_token: baseHook, + send_sms: baseHook, + send_email: baseHook, + before_user_created: baseHook, + }; - it("prefers an env-provided signing_keys_path over config.toml's", () => { - const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; - const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; - writeSigningKeys(tempRoot.current, [envJwk]); - const supabaseDir = join(tempRoot.current, "supabase"); - writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [header] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ - kid: "env-kid", - }); + it("leaves every hook disabled when nothing is configured or overridden", () => { + const resolved = resolveAuthHooks(allHooks); + expect(resolved.customAccessToken.enabled).toBe(false); + expect(resolved.mfaVerificationAttempt.enabled).toBe(false); }); }); - describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { - // `env(VAR)` indirection resolves inside any string field, including a `SUPABASE_*` override - // value itself, not just a config.toml literal. - const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; - - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - delete process.env["INDIRECT_JWT_SECRET"]; - delete process.env["INDIRECT_DB_PORT"]; - delete process.env["INDIRECT_API_ENABLED"]; + describe("resolveAuthExternalProviders", () => { + it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { + const authDocument = { + external: { + my_custom: { + enabled: "true", + client_id: "custom-client-id", + skip_nonce_check: "false", + email_optional: "TRUE", + }, + }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(true); + expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); + expect(resolved["my_custom"]?.emailOptional).toBe(true); }); - it("resolves a string override's env(VAR) indirection", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("c".repeat(32)); + it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, + }; + expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external)).toThrow( + '"not-a-bool" is not a valid boolean', + ); }); - it("resolves a port override's env(VAR) indirection", () => { - process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; - process.env["INDIRECT_DB_PORT"] = "54329"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { + const authDocument = { + external: { my_custom: { client_id: "custom-client-id" } }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(false); }); - it("resolves a bool override's env(VAR) indirection", () => { - process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; - process.env["INDIRECT_API_ENABLED"] = "false"; - const config = baseConfig({ - api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("weakly coerces a raw numeric custom-provider boolean by truthiness", () => { + const authDocument = { + external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(true); }); - it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, + }; + expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external)).toThrow( + '"1,2" is not a valid boolean', + ); }); }); - describe("non-auth SUPABASE_* env overrides", () => { - const ENV_KEYS = [ - "SUPABASE_DB_PORT", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_API_PORT", - "SUPABASE_API_EXTERNAL_URL", - "SUPABASE_STUDIO_API_URL", - ] as const; - - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - }); - - it("overrides db.port for the derived DB URL and the exposed dbPort", () => { - process.env["SUPABASE_DB_PORT"] = "54329"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); - expect(values.dbPort).toBe(54329); - }); - - it("overrides studio.port for the derived Studio URL", () => { - process.env["SUPABASE_STUDIO_PORT"] = "54330"; - const config = baseConfig({ studio: { port: 54323 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.studioUrl).toBe("http://127.0.0.1:54330"); - }); - - it("overrides local_smtp.port for the derived Mailpit URL", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; - const config = baseConfig({ local_smtp: { port: 54324 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); + describe("rawUnmodeledBool", () => { + it("returns false for an absent value", () => { + expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); }); - it("overrides api.port for every API-derived URL", () => { - process.env["SUPABASE_API_PORT"] = "54332"; - const config = baseConfig({ api: { port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://127.0.0.1:54332"); - expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); + it("passes a real boolean through unchanged", () => { + expect(rawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); }); - it("overrides api.external_url even when config.toml sets one", () => { - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; - const config = baseConfig({ api: { external_url: "https://config.example" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://env-override.example"); + it("coerces a raw number by truthiness", () => { + expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); + expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); }); - it("treats an empty non-auth env var as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_PORT"] = ""; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + it("parses boolean-ish strings", () => { + expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); + expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); }); - it.each([ - "SUPABASE_DB_PORT", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_API_PORT", - ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { - process.env[envKey] = "abc"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, + it("throws on an unparsable string instead of silently disabling it", () => { + expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( + '"not-a-bool" is not a valid boolean', ); }); - it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "99999"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, + it("throws on an array or table value instead of coercing it", () => { + expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow(CliConfigValueError); + expect(() => rawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( + CliConfigValueError, ); }); + }); - it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_PORT"] = "010"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:8/postgres"); - }); - - it("resolves a 0x-prefixed SUPABASE_DB_PORT override as hex", () => { - process.env["SUPABASE_DB_PORT"] = "0x1F90"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8080); + describe("resolveDbSettingsEnvOverrides", () => { + it("returns the configured settings unchanged when nothing is overridden", () => { + const settings = { shared_buffers: "128MB", max_connections: 100 }; + expect(resolveDbSettingsEnvOverrides(settings)).toEqual(settings); }); - it("still resolves a plain decimal SUPABASE_DB_PORT override with no leading zero", () => { - process.env["SUPABASE_DB_PORT"] = "5432"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(5432); + it("leaves an unconfigured field undefined when nothing is overridden", () => { + expect(resolveDbSettingsEnvOverrides({}).effective_cache_size).toBeUndefined(); }); - it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "0x1FFFF"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, - ); + it("leaves session_replication_role undefined when neither configured nor overridden", () => { + expect(resolveDbSettingsEnvOverrides({}).session_replication_role).toBeUndefined(); }); + }); - // Unlike the malformed/out-of-range cases above, db.port=0 is a required-field failure with - // no `enabled` gate, unlike api.port/studio.port/local_smtp.port. - it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { - process.env["SUPABASE_DB_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: db.port", - ); - }); + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useTempWorkdir("supabase-signing-keys-test-"); - // api.enabled defaults to true, so this rejection applies without an explicit - // `api.enabled = true`. - it("rejects a configured api.port of 0 when api is enabled", () => { - const config = baseConfig({ api: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: api.port", - ); - }); + it.live("signs anon/service_role with the first RS256 key in the file", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = yield* Effect.promise(() => importJWK(publicJwk, "RS256")); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { - process.env["SUPABASE_API_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: api.port", - ); - }); + const serviceRole = yield* Effect.promise(() => + jwtVerify(values.serviceRoleKey, publicKey), + ); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("does not reject a zero api.port when api is disabled", () => { - const config = baseConfig({ api: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it.live("resolves a relative signing_keys_path against /supabase", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }).pipe(Effect.provide(BunServices.layer)), + ); - // studio.enabled defaults to true, so this rejection applies without an explicit - // `studio.enabled = true`. - it("rejects a configured studio.port of 0 when studio is enabled", () => { - const config = baseConfig({ studio: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: studio.port", - ); - }); - - it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { - process.env["SUPABASE_STUDIO_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: studio.port", - ); - }); - - it("does not reject a zero studio.port when studio is disabled", () => { - const config = baseConfig({ studio: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { - const config = baseConfig({ studio: { api_url: "http://[::1" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, - ); - }); - - it("does not reject a malformed studio.api_url when studio is disabled", () => { - const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not throw for the default studio.api_url", () => { - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, - ); - }); - - // local_smtp.enabled defaults to true, so this rejection applies without an explicit - // `local_smtp.enabled = true`. - it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { - const config = baseConfig({ local_smtp: { port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: local_smtp.port", - ); - }); - - it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: local_smtp.port", - ); - }); - - it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { - const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("db.major_version (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - }); - - it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid db.major_version: 16.", - ); - }); - - it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid db.major_version: abc.", - ); - }); - - it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED env override", () => { - // Always validated eagerly, with no presence/enabled gate — same bucket as - // db.port/db.major_version above. - afterEach(() => { - delete process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; - }); - - it("does not throw for a valid SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "notabool"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, - ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', - ); - }); - }); - - describe("db.root_key (unmodeled raw-document field)", () => { - it("falls back to the default root key when absent", () => { - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.rootKey).toBe(POSTGRES_DEFAULT_ROOT_KEY); - }); - - it("uses a configured string root_key verbatim", () => { - const config = baseConfig(); - const document = { db: { root_key: "custom-root-key" } }; - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document); - expect(values.rootKey).toBe("custom-root-key"); - }); - - it("rejects a non-string root_key (e.g. a bare TOML integer), matching Go's Secret decode failure", () => { - const config = baseConfig(); - const document = { db: { root_key: 12345 } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", - ); - }); - }); - - // storage.buckets validation lives entirely in config-validate.unit.test.ts; this file has no - // bucket-related mechanics of its own. - - // Not gated on edge_runtime.enabled — an invalid value is rejected unconditionally. - describe("edge_runtime.deno_version (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - }); - - it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: edge_runtime.deno_version", - ); - }); - - it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: 3.", - ); - }); - - it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: abc.", - ); - }); - - it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("analytics (BigQuery backend required fields)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - afterEach(() => { - delete process.env["SUPABASE_ANALYTICS_ENABLED"]; - delete process.env["SUPABASE_ANALYTICS_BACKEND"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); - - it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; - const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_id", - ); - }); - - it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_id", - ); - }); - - it("accepts env-provided GCP fields overriding empty config.toml values", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; - const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidAnalyticsBackendEnvOverrideError, - ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', - ); - }); - }); - - describe("experimental.* (experimental.validate())", () => { - // Exercises this function's own fallback when no `document` (5th param) is supplied; the - // required-field/enabled checks themselves live in config-validate.unit.test.ts. - it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { - const config = baseConfig({ experimental: { webhooks: {} } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - afterEach(() => { - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; - }); - - it("enables webhooks purely via SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when the section omits enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "true"; - const config = baseConfig({ experimental: { webhooks: {} } }); - const document = { experimental: { webhooks: {} } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED override on an already-enabled section", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "notabool"; - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - const document = { experimental: { webhooks: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(InvalidBoolEnvOverrideError); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', - ); - }); - - it("rejects an invalid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - ); - }); - - it("accepts a valid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = '{"keywordCase":"upper"}'; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; - const config = baseConfig({ - experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.pgdelta.format_options"]), - ), - ).not.toThrow(); - }); - }); - - describe("SUPABASE_API_TLS_ENABLED env override", () => { - // Applied before the default `api.external_url` scheme is derived, so it can flip http/https - // even when config.toml disagrees. - afterEach(() => { - delete process.env["SUPABASE_API_TLS_ENABLED"]; - }); - - it("overrides api.tls.enabled from false to true", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); - - it("overrides api.tls.enabled from true to false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "false"; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); - }); - - it("does not override api.tls.enabled once api.external_url is set", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ api: { external_url: "http://config.example" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://config.example"); - }); - - it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - - it("treats an empty override as unset, matching Viper's default", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = ""; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); - }); - - describe("envOverrideRealtimeIpVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_IP_VERSION"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv4"); - }); - - it("overrides IPv4 to IPv6 via the env var", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv6"); - }); - - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - InvalidRealtimeIpVersionEnvOverrideError, - ); - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - 'Invalid config for realtime.ip_version: cannot parse "IPv5" as one of "IPv4", "IPv6"', - ); - }); - }); - - describe("envOverrideRealtimeMaxHeaderLength", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(4096); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(8192); - }); - - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - envOverrideRealtimeMaxHeaderLength(4096, { - SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", - }), - ).toBe(16384); - }); - - it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", - ); - }); - - it("accepts an override of exactly the uint64 max (2^64-1)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); - }); - - it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", - ); - }); - }); - - describe("envOverrideApiMaxRows", () => { - afterEach(() => { - delete process.env["SUPABASE_API_MAX_ROWS"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideApiMaxRows(1000, undefined)).toBe(1000); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_API_MAX_ROWS"] = "500"; - expect(envOverrideApiMaxRows(1000, undefined)).toBe(500); - }); - }); - - // Exercises `envOverrideMajorVersion`'s base-0 parsing directly; most literals below don't - // correspond to a supported Postgres major version, which the full pipeline would separately - // reject. - describe("envOverrideMajorVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideMajorVersion(17, undefined)).toBe(17); - }); - - it("resolves an octal leading-zero override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "010"; - expect(envOverrideMajorVersion(17, undefined)).toBe(8); - }); - - it("resolves a 0x-prefixed override as hex", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0x10"; - expect(envOverrideMajorVersion(17, undefined)).toBe(16); - }); - - it("resolves a 0b-prefixed override as binary", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0b101"; - expect(envOverrideMajorVersion(17, undefined)).toBe(5); - }); - - it("still resolves a plain decimal override with no leading zero", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - expect(envOverrideMajorVersion(17, undefined)).toBe(15); - }); - - it("permits an underscore digit separator between decimal digits", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; - expect(envOverrideMajorVersion(17, undefined)).toBe(1000); - }); - - it("rejects an invalid octal digit instead of silently falling back to decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "08"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: 08.", - ); - }); - - it("rejects a signed override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "+5"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: +5.", - ); - }); - }); - - describe("envOverridePoolMode", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverridePoolMode("transaction", undefined)).toBe("transaction"); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; - expect(envOverridePoolMode("transaction", undefined)).toBe("session"); - }); - - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( - InvalidPoolModeEnvOverrideError, - ); - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( - 'Invalid config for db.pooler.pool_mode: cannot parse "invalid" as one of "transaction", "session"', - ); - }); - }); - - describe("envOverrideEdgeRuntimePolicy", () => { - afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("oneshot"); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("per_worker"); - }); - - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - InvalidEdgeRuntimePolicyEnvOverrideError, - ); - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - 'Invalid config for edge_runtime.policy: cannot parse "invalid" as one of "per_worker", "oneshot"', - ); - }); - }); - - describe("envOverrideDefaultPoolSize", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(20); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"] = "40"; - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(40); - }); - }); - - describe("envOverrideMaxClientConn", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideMaxClientConn(100, undefined)).toBe(100); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"] = "200"; - expect(envOverrideMaxClientConn(100, undefined)).toBe(200); - }); - }); - - describe("resolveAuthCaptcha", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); - - it("returns undefined when captcha is not configured", () => { - expect(resolveAuthCaptcha(undefined, undefined, undefined)).toBeUndefined(); - }); - - it("overrides enabled/provider when the section is present in the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: false, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(true); - expect(resolved?.provider).toBe("turnstile"); - }); - - it("does not apply an env override when [auth.captcha] is absent from the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const resolved = resolveAuthCaptcha( - {}, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(false); - }); - - it("decrypts an encrypted: captcha secret", () => { - process.env["DOTENV_PRIVATE_KEY"] = - "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { - enabled: true, - provider: "hcaptcha", - secret: - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", - }, - undefined, - ); - expect(resolved?.secret).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - new Set(["auth.captcha.secret"]), - ); - expect(resolved?.secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - ), - ).toThrow("failed to parse config: missing private key"); - }); - - it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.provider"]), - ); - expect(resolved?.provider).toBe("hcaptcha"); - }); - - it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.provider).toBe("turnstile"); - }); - }); - - describe("resolveAuthEmail", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; - }); - - it("keeps an explicit empty subject present in the raw document, not omitted", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { subject: "" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe(""); - }); - - it("omits the subject when the key is absent from the raw document", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBeUndefined(); - }); - - it("prefers an env-overridden subject over the raw document's presence, even when absent", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Overridden subject"; - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); - }); - - describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; - }); - - it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail( - config.auth.email, - undefined, - undefined, - new Set(["auth.email.max_frequency"]), - ); - expect(resolved.max_frequency).toBe("1m"); - }); - - it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail(config.auth.email, undefined, undefined); - expect(resolved.max_frequency).toBe("5s"); - }); - }); - }); - - describe("resolveAuthHooks", () => { - const baseHook = { enabled: false, uri: "", secrets: "" }; - const allHooks = { - mfa_verification_attempt: baseHook, - password_verification_attempt: baseHook, - custom_access_token: baseHook, - send_sms: baseHook, - send_email: baseHook, - before_user_created: baseHook, - }; - - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; - }); - - it("leaves every hook disabled when nothing is configured or overridden", () => { - const resolved = resolveAuthHooks(undefined, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); - - it("overrides enabled/uri when the hook's section is present in the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://example.com/hook"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - const resolved = resolveAuthHooks(authDocument, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(true); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); - - it("does not apply an env override when the hook's section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - const resolved = resolveAuthHooks({}, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); - }); - - it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => - resolveAuthHooks( - authDocument, - allHooks, - undefined, - new Set(["auth.hook.custom_access_token.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => resolveAuthHooks(authDocument, allHooks, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); - - it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const hooksWithRemoteUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteUri, - undefined, - new Set(["auth.hook.custom_access_token.uri"]), - ); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - }); - - it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; - const hooksWithRemoteSecrets = { - ...allHooks, - custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteSecrets, - undefined, - new Set(["auth.hook.custom_access_token.secrets"]), - ); - expect(resolved.customAccessToken.secrets).toBe("remote-secret"); - }); - - it("still applies the env override for uri when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; - const hooksWithLocalUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks(authDocument, hooksWithLocalUri, undefined); - expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); - }); - }); - - describe("resolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => - resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => resolveAuthMfa(mfa, undefined)).toThrow('cannot parse "not-a-bool" as a bool'); - }); - - it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"])); - expect(resolved.phone.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; - }); - - it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = resolveAuthMfa(mfa, undefined); - expect(resolved.phone.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; - }); - - it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.max_frequency"])); - expect(resolved.phone.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); - - it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = resolveAuthMfa(mfa, undefined); - expect(resolved.phone.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); - }); - - describe("resolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => - resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); - - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.pass"]), - ); - expect(resolved?.pass).toBe("remote-pass"); - }); - - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.host"]), - ); - expect(resolved?.host).toBe("smtp.remote.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.host).toBe("smtp.env.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); - - it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.user"]), - ); - expect(resolved?.user).toBe("remote-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.user).toBe("env-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); - - it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.admin_email"]), - ); - expect(resolved?.adminEmail).toBe("remote@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.adminEmail).toBe("env@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.sender_name"]), - ); - expect(resolved?.senderName).toBe("Remote Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.senderName).toBe("Env Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); - }); - - describe("resolveAuthExternalProviders", () => { - it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { - const authDocument = { - external: { - my_custom: { - enabled: "true", - client_id: "custom-client-id", - skip_nonce_check: "false", - email_optional: "TRUE", - }, - }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); - expect(resolved["my_custom"]?.emailOptional).toBe(true); - }); - - it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, - }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - - it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { - const authDocument = { - external: { my_custom: { client_id: "custom-client-id" } }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(false); - }); - - it("weakly coerces a raw numeric custom-provider boolean by truthiness, matching Go's WeaklyTypedInput decode", () => { - const authDocument = { - external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - }); - - it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, - }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "1,2" as a bool'); - }); - - it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all, matching Go's ejected default template", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client-id", - SUPABASE_AUTH_EXTERNAL_APPLE_SECRET: "apple-secret", - SUPABASE_AUTH_EXTERNAL_APPLE_URL: "https://appleid.apple.com", - }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["apple"]).toEqual({ - enabled: true, - clientId: "apple-client-id", - secret: "apple-secret", - url: "https://appleid.apple.com", - redirectUri: "", - skipNonceCheck: false, - emailOptional: false, - }); - }); - - it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets Go's default-template exception", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", - }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["google"]).toBeUndefined(); - expect(resolved["apple"]?.enabled).toBe(false); - }); - }); - - describe("resolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - it("prefers a remote-set auth.external..secret over a malformed SUPABASE_AUTH_EXTERNAL__SECRET", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.secret"]), - ); - expect(resolved["my_custom"]?.secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__SECRET when no remote block matched", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), - ).toThrow("failed to parse config: missing private key"); - }); - - it("prefers a remote-set auth.external..enabled over a malformed SUPABASE_AUTH_EXTERNAL__ENABLED", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__ENABLED when no remote block matched", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - - it("prefers a remote-set auth.external..client_id over a conflicting SUPABASE_AUTH_EXTERNAL__CLIENT_ID", () => { - const authDocument = { - external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, - }; - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.client_id"]), - ); - expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); - }); - }); - - describe("rawUnmodeledBool", () => { - it("returns false for an absent value, matching Go's zero-value bool default", () => { - expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); - }); - - it("passes a real boolean through unchanged", () => { - expect(rawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); - }); - - it("weakly coerces a raw number by truthiness, matching mapstructure's WeaklyTypedInput decodeBool", () => { - expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); - }); - - it("parses a valid boolean-ish string the way Go's strconv.ParseBool does", () => { - expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); - }); - - it("throws on an unparsable string instead of silently disabling it", () => { - expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); - - it("throws on an array or table value — mapstructure's decodeBool errors on these unconditionally, never weakly coerced", () => { - expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( - InvalidBoolEnvOverrideError, - ); - expect(() => rawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - - describe("resolveDbSettingsEnvOverrides", () => { - const ALL_OVERRIDE_NAMES = [ - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - "SUPABASE_DB_SETTINGS_WORK_MEM", - ]; - - afterEach(() => { - for (const name of ALL_OVERRIDE_NAMES) delete process.env[name]; - }); - - it("returns the configured settings unchanged when nothing is overridden", () => { - const settings = { shared_buffers: "128MB", max_connections: 100 }; - expect(resolveDbSettingsEnvOverrides(settings, undefined)).toEqual(settings); - }); - - it("leaves an unconfigured field undefined when nothing is overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).effective_cache_size).toBeUndefined(); - }); - - it("overrides a string field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; - expect( - resolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, undefined).shared_buffers, - ).toBe("256MB"); - }); - - it("sets a string field via the env var even when not configured at all", () => { - process.env["SUPABASE_DB_SETTINGS_WORK_MEM"] = "8MB"; - expect(resolveDbSettingsEnvOverrides({}, undefined).work_mem).toBe("8MB"); - }); - - it("overrides a uint field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "200"; - expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(200); - }); - - it("rejects a non-numeric uint override", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Invalid db.settings.max_connections", - ); - }); - - it("resolves a 0x-prefixed uint override as hex", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; - expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(16); - }); - - it("rejects a uint override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", - ); - }); - - it("overrides the boolean field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "true"; - expect( - resolveDbSettingsEnvOverrides({ track_commit_timestamp: false }, undefined) - .track_commit_timestamp, - ).toBe(true); - }); - - it("rejects a malformed boolean override", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "not-a-bool"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - - it("overrides the session_replication_role enum field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "replica"; - expect( - resolveDbSettingsEnvOverrides({ session_replication_role: "origin" }, undefined) - .session_replication_role, - ).toBe("replica"); - }); - - it("leaves session_replication_role undefined when neither configured nor overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).session_replication_role).toBeUndefined(); - }); - - it("rejects an invalid session_replication_role override", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - InvalidSessionReplicationRoleEnvOverrideError, - ); - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - 'Invalid config for db.settings.session_replication_role: cannot parse "invalid" as one of "origin", "replica", "local"', - ); - }); - - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - resolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" }) - .shared_buffers, - ).toBe("512MB"); - }); - }); - - describe("auth.signing_keys_path (asymmetric JWT signing)", () => { - const tempRoot = useTempWorkdir("supabase-signing-keys-test-"); - - it("signs anon/service_role with the first RS256 key in the file", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - - const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); - expect(serviceRole.payload).toMatchObject({ role: "service_role" }); - }); - - it("resolves a relative signing_keys_path against /supabase", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); - - it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); - const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); - expect(values.anonKey.split(".")).toHaveLength(3); - }); - - it("still prefers an explicit anon_key/service_role_key over signing keys", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { - signing_keys_path: "signing_keys.json", - anon_key: "configured-anon", - service_role_key: "configured-service-role", - }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey).toBe("configured-anon"); - expect(values.serviceRoleKey).toBe("configured-service-role"); - }); - - it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { - writeSigningKeys(tempRoot.current, []); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [, payload] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ - iss: "supabase-demo", - }); - }); - - it("throws a Go-worded error when the signing keys file does not exist", () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); - - it("throws a Go-worded error when the signing keys file is malformed JSON", () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'failed to decode signing keys: JSON Parse error: Unexpected identifier "not"', - ); - }); - - it("throws when the first key uses an unsupported algorithm", () => { - writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "unsupported algorithm: RS512", - ); - }); - - it("skips reading a missing signing_keys_path when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "missing.json" }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - // Disabled auth with a configured signing-keys path still signs with the default ES256 key, - // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. - const publicKey = await importJWK( - { ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, - "ES256", - ); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); - }); - - describe("SUPABASE_AUTH_ENABLED env override", () => { - // Reads the post-override `auth.enabled` value, not raw TOML, so an env-only disable/enable - // still gates whether `signing_keys_path` is read. - afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; - }); - - it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; - const config = baseConfig({ - auth: { enabled: true, signing_keys_path: "missing.json" }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); - - it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "missing.json" }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - }); - - describe("auth.site_url (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; - delete process.env["SUPABASE_AUTH_SITE_URL"]; - }); - - it("rejects an empty site_url when auth is enabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; - const config = baseConfig({ auth: { enabled: false, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.site_url", - ); - }); - - it("does not throw when auth is disabled only via env, however empty site_url is", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; - const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("accepts an env-provided site_url overriding an empty config.toml value", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; - const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("exposes the overridden site_url on the returned values, not just for validation", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; - const config = baseConfig({ auth: { enabled: true, site_url: "http://127.0.0.1:3000" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authSiteUrl).toBe("http://localhost:4000"); - }); - }); - }); - - describe("auth.* flat scalar env overrides (GoTrue container env, not just validation)", () => { - const AUTH_SCALAR_ENV_KEYS = [ - "SUPABASE_AUTH_JWT_ISSUER", - "SUPABASE_AUTH_JWT_EXPIRY", - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - "SUPABASE_AUTH_ENABLE_SIGNUP", - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - ]; - afterEach(() => { - for (const key of AUTH_SCALAR_ENV_KEYS) delete process.env[key]; - }); - - it("overrides every flat auth.* scalar GoTrue needs, not just the ones Validate checks", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://issuer.example.com"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = - "https://a.example.com,https://b.example.com"; - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; - process.env["SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS"] = "true"; - process.env["SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION"] = "false"; - process.env["SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL"] = "20"; - process.env["SUPABASE_AUTH_ENABLE_MANUAL_LINKING"] = "true"; - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "12"; - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "lower_upper_letters_digits"; - - const config = baseConfig({ - auth: { - jwt_expiry: 3600, - additional_redirect_urls: [], - enable_signup: true, - enable_anonymous_sign_ins: false, - enable_refresh_token_rotation: true, - refresh_token_reuse_interval: 10, - enable_manual_linking: false, - minimum_password_length: 6, - password_requirements: "", - }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - - expect(values.authJwtIssuer).toBe("https://issuer.example.com"); - expect(values.authJwtExpiry).toBe(7200); - expect(values.authAdditionalRedirectUrls).toEqual([ - "https://a.example.com", - "https://b.example.com", - ]); - expect(values.authEnableSignup).toBe(false); - expect(values.authEnableAnonymousSignIns).toBe(true); - expect(values.authEnableRefreshTokenRotation).toBe(false); - expect(values.authRefreshTokenReuseInterval).toBe(20); - expect(values.authEnableManualLinking).toBe(true); - expect(values.authMinimumPasswordLength).toBe(12); - expect(values.authPasswordRequirements).toBe("lower_upper_letters_digits"); - }); - - it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override, matching Go's UnmarshalText", () => { - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid auth.password_requirements: bogus", - ); - }); - }); - - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - - describe("auth.captcha env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); - - it("rejects a captcha section enabled only via env with no provider", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig({ auth: { captcha: { enabled: false } } }); - const document = { auth: { captcha: { enabled: false } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.captcha.provider"); - }); - - it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("accepts env-provided provider/secret overriding an enabled captcha section", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.passkey / auth.webauthn env overrides", () => { - // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread - // a raw `document` object through explicitly instead of relying on `baseConfig`. - afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - }); - - it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - }); - - it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = - "http://localhost:3000,http://localhost:3001"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { - const config = baseConfig(); - const document = { auth: { passkey: { enabled: "not-a-bool" } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - }); - - describe("auth.hook.* env overrides", () => { - // The hook schema always decodes a default `{ enabled: false }` regardless of file presence, - // so presence here is read from the raw `document`, not the decoded `config`. - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; - }); - - it("rejects a hook section enabled only via env with no uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { hook: { send_email: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.hook.send_email.uri"); - }); - - it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; - const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); - const document = { auth: { hook: { send_email: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.email.smtp env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("rejects an smtp section enabled only via env with no host", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.email.smtp.host"); - }); - - it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(InvalidPortEnvOverrideError); - }); - - it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.mfa env overrides", () => { - // These are plain value-typed fields with no presence gate, unlike hooks/smtp above — they're - // overridable unconditionally. - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; - }); - - it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", - ); - }); - - it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - - describe("auth.third_party env overrides", () => { - // Same as auth.mfa above — including workos, whose default template omits the whole section - // yet leaves it still unconditionally overridable. - afterEach(() => { - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; - }); - - it("rejects a third-party provider enabled only via env with no required field configured", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); - - it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not enable a third-party provider purely from a required-field env override", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.email.template/notification (content_path validation)", () => { - const tempRoot = useTempWorkdir("supabase-email-templates-test-"); - - it("rejects a template content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "missing-invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); - - it("rejects an absolute template content_path outside the project root", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "/etc/hosts" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', - ); - }); - - it("resolves a relative template content_path against the workdir itself, not /supabase", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("does not throw a template with no content_path configured", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects an enabled notification content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", - ); - }); - - it("resolves a relative notification content_path against the workdir", () => { - const templateDir = join(tempRoot.current, "supabase", "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "pw-changed.html"), ""); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { - enabled: true, - content_path: "supabase/templates/pw-changed.html", - }, - }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("does not throw a disabled notification's missing content_path", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: false, content_path: "missing.html" }, - }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("does not throw a missing template content_path when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects a template content key present without content_path", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { - auth: { email: { template: { invite: { content: "Hi" } } } }, - }), - ).toThrow( - "Invalid config for auth.email.template.invite.content: please use content_path instead", - ); - }); - }); - - describe("auth.email.template/notification env overrides", () => { - // No raw-document presence gate needed here: `email.template`/`email.notification` are - // `Schema.Record`s, so the decoded config already reflects presence. - const tempRoot = useTempWorkdir("supabase-email-template-env-test-"); - - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"]; - }); - - it("lets an env-provided template content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects a notification enabled only via env with a missing content_path file", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: false, content_path: "missing.html" } }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", - ); - }); - - it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("lets an env-provided notification content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "pw-changed.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects a template _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content: please use content_path instead", - ); - }); - - it("rejects an enabled notification's _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content: please use content_path instead", - ); - }); - - it("does not validate a disabled notification's _CONTENT env override", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.template.invite.content_path"]), - ), - ).not.toThrow(); - }); - - it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); - - it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: true, content_path: "pw-changed.html" }, - }, - }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.content_path"]), - ), - ).not.toThrow(); - }); - - it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.enabled"]), - ), - ).not.toThrow(); - }); - }); - - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - - describe("auth.external (external.validate(), D-only, ported to L)", () => { - // Unmodeled external providers are silently dropped by the decoded config, so this reads the - // raw `document` (5th param) instead. - it("rejects an enabled unmodeled external provider missing client_id", () => { - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.client_id"); - }); - - it("rejects an enabled unmodeled external provider missing secret", () => { - const config = baseConfig(); - const document = { - auth: { external: { custom: { enabled: true, client_id: "abc" } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.secret"); - }); - - it("does not require a secret for apple/google providers", () => { - const config = baseConfig(); - const document = { - auth: { external: { apple: { enabled: true, client_id: "abc" } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("skips deprecated linkedin/slack providers", () => { - const config = baseConfig(); - const document = { auth: { external: { slack: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not validate a disabled unmodeled external provider", () => { - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("skips the check entirely when no document is threaded through", () => { - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.external env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; - }); - - it("rejects a provider enabled only via env with no client_id", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.client_id"); - }); - - it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.sms env overrides (provider switch)", () => { - // Validates only the first enabled provider, in priority order (twilio, twilio_verify, - // messagebird, textlocal, vonage), re-run here against the raw document with env overrides - // applied. - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; - delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; - }); - - it("rejects a provider enabled only via env with missing required fields", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { sms: { twilio: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); - }); - - it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const config = baseConfig(); - const document = { auth: { sms: { twilio: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("only validates the first enabled provider in Go's fixed priority order", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; - const config = baseConfig(); - const document = { - auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); - }); - - it("throws for a provider enabled only via env with missing required fields even when the document has no auth.sms section at all", () => { - // Unlike the other 4 providers, twilio's presence isn't gated on the document — the default - // config always registers `auth.sms.twilio.*`, so enabling only via env still fails - // validation instead of doing nothing. - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.sms.twilio.account_sid", - ); - }); - - it("resolves a fully env-only twilio configuration with no auth.sms.twilio document section", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.twilio.enabled).toBe(true); - expect(resolved.twilio.account_sid).toBe("AC123"); - expect(resolved.twilio.message_service_sid).toBe("MG123"); - expect(resolved.twilio.auth_token).toBe("tok"); - }); - - it("still does not synthesize messagebird purely from an env override when the section is absent from the document", () => { - // Unlike twilio, messagebird has no entry in the default config template, so an absent - // section genuinely means it was never registered. - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; - const config = baseConfig(); - const resolved = resolveAuthSms(undefined, config.auth.sms, undefined); - expect(resolved.messagebird.enabled).toBe(false); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("resolveAuthSms (top-level scalars)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"]; - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("overrides enable_signup/enable_confirmations/max_frequency/template with no presence gate", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; - // A provider must be enabled, or `enable_signup` gets downgraded to false regardless of the - // override. - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - expect(resolved.enable_confirmations).toBe(true); - expect(resolved.max_frequency).toBe("10s"); - expect(resolved.template).toBe("Your OTP is {{ .Code }}"); - }); - - it("leaves the scalars at their configured values when nothing is overridden", () => { - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - max_frequency: "5s", - twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - expect(resolved.max_frequency).toBe("5s"); - }); - }); - - describe("resolveAuthSms (disables phone login with no provider enabled)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - }); - - it("downgrades enable_signup to false when configured true with no provider enabled", () => { - const configured = { ...baseConfig().auth.sms, enable_signup: true }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(false); - }); - - it("downgrades an env-overridden enable_signup to false with no provider enabled", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.enable_signup).toBe(false); - }); - - it("leaves enable_signup alone when a provider is enabled", () => { - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - }); - - it("leaves enable_signup at false when already false with no provider enabled", () => { - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.enable_signup).toBe(false); - }); - }); - - describe("resolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => resolveAuthSms(undefined, configured, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); - - it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.vonage.enabled"])), - ).not.toThrow(); - }); - - it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, - }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), - ); - expect(resolved.vonage.api_secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; - const authDocument = { sms: { vonage: {} } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, - }; - expect(() => resolveAuthSms(authDocument, configured, undefined)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, - }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.twilio.account_sid"]), - ); - expect(resolved.twilio.account_sid).toBe("remote-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - }); - - it("still applies SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.twilio.account_sid).toBe("env-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - }); - - it("prefers a remote-set auth.sms.vonage.from over a conflicting SUPABASE_AUTH_SMS_VONAGE_FROM", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; - const authDocument = { sms: { vonage: { from: "remote-from" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, - }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.from"]), - ); - expect(resolved.vonage.from).toBe("remote-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; - }); - - it("still applies SUPABASE_AUTH_SMS_VONAGE_FROM when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; - const authDocument = { sms: { vonage: { from: "remote-from" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, - }; - const resolved = resolveAuthSms(authDocument, configured, undefined); - expect(resolved.vonage.from).toBe("env-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; - }); - - it("prefers a remote-set auth.sms.vonage.api_key over a conflicting SUPABASE_AUTH_SMS_VONAGE_API_KEY", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; - const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, - }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.api_key"]), - ); - expect(resolved.vonage.api_key).toBe("remote-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; - }); - - it("still applies SUPABASE_AUTH_SMS_VONAGE_API_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; - const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, - }; - const resolved = resolveAuthSms(authDocument, configured, undefined); - expect(resolved.vonage.api_key).toBe("env-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; - }); - - it("prefers a remote-set auth.sms.template over a conflicting SUPABASE_AUTH_SMS_TEMPLATE", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; - const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.template"]), - ); - expect(resolved.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("still applies SUPABASE_AUTH_SMS_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; - const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("prefers a remote-set auth.sms.max_frequency over a conflicting SUPABASE_AUTH_SMS_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; - const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.max_frequency"]), - ); - expect(resolved.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - }); - - it("still applies SUPABASE_AUTH_SMS_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; - const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - }); - - it("still aborts resolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const base = baseConfig(); - const config: CliConfig = { - ...base, - auth: { - ...base.auth, - enabled: true, - sms: { - ...base.auth.sms, - enable_signup: true, - vonage: { - ...base.auth.sms.vonage, - enabled: true, - from: "12345", - api_key: "key", - api_secret: "secret", - }, - }, - }, - }; - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - expect(() => - resolveLocalConfigValues( + it.live("uses an absolute signing_keys_path as-is, without joining the workdir", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = yield* join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = yield* resolveLocalConfigValues( config, "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.sms.enable_signup"]), - ), - ).not.toThrow(); - }); - }); - - describe("api.tls (cert/key validation)", () => { - const tempRoot = useTempWorkdir("supabase-api-tls-test-"); - - function writeTlsFile(workdir: string, name: string, contents = "dummy") { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); - } - - it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { - const config = baseConfig({ api: { tls: { enabled: true } } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read - // behavior below is tested here. - - it("throws a Go-worded error when the configured cert file does not exist", () => { - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); - - it("throws a Go-worded error when the configured key file does not exist", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS key: ", - ); - }); - - it("succeeds when both cert_path and key_path are readable", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { - tls: { - enabled: true, - cert_path: "/cert.pem", - key_path: "/key.pem", - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("skips TLS validation entirely when api is disabled", () => { - const config = baseConfig({ - api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_API_ENABLED"]; - delete process.env["SUPABASE_API_TLS_ENABLED"]; - }); + "/some/unrelated/workdir", + ); + expect(values.anonKey.split(".")).toHaveLength(3); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("skips TLS validation when api is disabled only via env", () => { - process.env["SUPABASE_API_ENABLED"] = "false"; + it.live("still prefers an explicit anon_key/service_role_key over signing keys", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); const config = baseConfig({ - api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ - api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.key_path", + it.live("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [, payload] = values.anonKey.split("."); + expect(yield* decodeJson(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject( + { + iss: "supabase-demo", + }, ); - }); - }); - }); -}); - -describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // Once a remote block sets a field, a conflicting `SUPABASE_*` env var must not be consulted - // for it — verified per field below. - afterEach(() => { - for (const name of [ - "SUPABASE_DB_MAJOR_VERSION", - "SUPABASE_AUTH_JWT_SECRET", - "SUPABASE_DB_ROOT_KEY", - "SUPABASE_API_PORT", - "SUPABASE_API_TLS_ENABLED", - "SUPABASE_API_EXTERNAL_URL", - "SUPABASE_DB_PORT", - "SUPABASE_AUTH_SITE_URL", - "SUPABASE_AUTH_JWT_EXPIRY", - "SUPABASE_AUTH_ANON_KEY", - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - "SUPABASE_STUDIO_API_URL", - "SUPABASE_STUDIO_OPENAI_API_KEY", - "SUPABASE_AUTH_PUBLISHABLE_KEY", - "SUPABASE_AUTH_SECRET_KEY", - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - "SUPABASE_AUTH_ENABLED", - "SUPABASE_ANALYTICS_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "SUPABASE_API_ENABLED", - "SUPABASE_STUDIO_ENABLED", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_ENABLED", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_AUTH_ENABLE_SIGNUP", - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - "SUPABASE_AUTH_PASSKEY_ENABLED", - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", - ]) { - delete process.env[name]; - } - }); - - const tempRoot = useTempWorkdir("supabase-remote-signing-keys-test-"); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.signing_keys_path"]), - ), - ).not.toThrow(); - }); - - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig({ db: { major_version: 14 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.major_version"]), - ), - ).not.toThrow(); - }); - it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig({ db: { major_version: 14 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid db.major_version: abc", + it.live("throws an error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; - const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_secret"]), + it.live("throws an error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const supabaseDir = yield* join(tempRoot.current, "supabase"); + yield* mkdirp(supabaseDir); + yield* writeFile(yield* join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain('failed to decode signing keys: JSON Parse error: Unexpected identifier "not"'); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); - }); - it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { - process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; - const config = baseConfig(); - const document = { db: { root_key: "remote-root-key" } }; - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["db.root_key"]), + it.live("throws when the first key uses an unsupported algorithm", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("unsupported algorithm: RS512"); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.rootKey).toBe("remote-root-key"); - }); - it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; - const config = baseConfig({ - auth: { - enabled: true, - third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), - ), - ).not.toThrow(); - }); - - it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; - const config = baseConfig({ - auth: { - enabled: true, - third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.clerk has invalid domain", + it.live("skips reading a missing signing_keys_path when auth is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - describe("api.tls.cert_path/key_path — remoteOverrideKeys (review: PRRT_kwDOErm0O86W8ZYk)", () => { - const tempRoot = useTempWorkdir("supabase-api-tls-remote-test-"); - - function writeTlsFile(workdir: string, name: string, contents = "dummy") { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); - } - - afterEach(() => { - delete process.env["SUPABASE_API_TLS_CERT_PATH"]; - delete process.env["SUPABASE_API_TLS_KEY_PATH"]; - }); - - it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { - // The env vars point at files that don't exist; the load only succeeds if the remote-set - // paths win instead. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["api.tls.cert_path", "api.tls.key_path"]), - ), - ).not.toThrow(); - }); - - it("still uses the env override when no remote block matched", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); - }); - it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { - process.env["SUPABASE_API_PORT"] = "9999"; - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; - const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.port", "api.tls.enabled", "api.external_url"]), + it.live( + "skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", + () => + Effect.gen(function* () { + const supabaseDir = yield* join(tempRoot.current, "supabase"); + yield* mkdirp(supabaseDir); + yield* writeFile(yield* join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Disabled auth with a configured signing-keys path still signs with the default ES256 key, + // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. + const publicKey = yield* Effect.promise(() => + importJWK({ ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, "ES256"), + ); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); }); - it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { - process.env["SUPABASE_DB_PORT"] = "9999"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.port"]), - ); - expect(values.dbPort).toBe(54322); - expect(values.dbUrl).toContain(":54322/postgres"); - }); + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. - it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; - const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.site_url", "auth.jwt_expiry"]), - ); - expect(values.authSiteUrl).toBe("https://remote.test"); - expect(values.authJwtExpiry).toBe(3600); - }); + describe("auth.passkey / auth.webauthn env overrides", () => { + // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread + // a raw `document` object through explicitly instead of relying on `baseConfig`. - it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; - const config = baseConfig({ - auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, - }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.anon_key", "auth.service_role_key"]), + it.live( + "throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", + () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { auth: { passkey: { enabled: "not-a-bool" } } }; + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document))) + .message, + ).toContain('"not-a-bool" is not a valid boolean'); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.anonKey).toBe("remote-anon-key"); - expect(values.serviceRoleKey).toBe("remote-service-role-key"); }); - it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; - const config = baseConfig({ studio: { api_url: "http://remote.test" } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.api_url"]), - ), - ).not.toThrow(); - }); + describe("auth.email.template/notification (content_path validation)", () => { + const tempRoot = useTempWorkdir("supabase-email-templates-test-"); - it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.openai_api_key"]), + it.live("rejects a template content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.template.invite.content_path: "); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.openaiApiKey).toBe("remote-openai-key"); - }); - it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", + it.live("rejects an absolute template content_path outside the project root", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "/etc/hosts" } } }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain( + 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', + ); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ - auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, - }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.publishable_key", "auth.secret_key"]), + it.live( + "resolves a relative template content_path against the workdir itself, not /supabase", + () => + Effect.gen(function* () { + yield* writeFile(yield* join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.publishableKey).toBe("remote-publishable-key"); - expect(values.secretKey).toBe("remote-secret-key"); - }); - it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", + it.live("does not throw a template with no content_path configured", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", + it.live("rejects an enabled notification content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("Invalid config for auth.email.notification.password_changed.content_path: "); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - const config = baseConfig({ db: { settings: { max_connections: 100 } } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.settings.max_connections"]), - ), - ).not.toThrow(); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + it.live("resolves a relative notification content_path against the workdir", () => + Effect.gen(function* () { + const templateDir = yield* join(tempRoot.current, "supabase", "templates"); + yield* mkdirp(templateDir); + yield* writeFile(yield* join(templateDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { + enabled: true, + content_path: "supabase/templates/pw-changed.html", + }, + }, + }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ analytics: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.enabled"]), - ), - ).not.toThrow(); - }); - it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ analytics: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', + it.live("does not throw a disabled notification's missing content_path", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("prefers a remote-set analytics.gcp_project_id over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_ID", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; - const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_id"]), + it.live("does not throw a missing template content_path when auth is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: false, + email: { template: { invite: { content_path: "missing.html" } } }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.gcpProjectId).toBe("remote-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - }); - it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_ID when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; - const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpProjectId).toBe("env-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - }); - - it("prefers a remote-set analytics.gcp_project_number over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; - const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_number"]), + it.live("rejects a template content key present without content_path", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect( + (yield* Effect.flip( + resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + )).message, + ).toContain( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.gcpProjectNumber).toBe("111"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; }); - it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; - const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpProjectNumber).toBe("999"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - }); + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. - it("prefers a remote-set analytics.gcp_jwt_path over a conflicting SUPABASE_ANALYTICS_GCP_JWT_PATH", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; - const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_jwt_path"]), + describe("auth.external (external.validate(), D-only, ported to L)", () => { + // Unmodeled external providers are silently dropped by the decoded config, so this reads the + // raw `document` (5th param) instead. + it.live("rejects an enabled unmodeled external provider missing client_id", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document))) + .message, + ).toContain("Missing required field in config: auth.external.custom.client_id"); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.gcpJwtPath).toBe("remote-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); - - it("still applies SUPABASE_ANALYTICS_GCP_JWT_PATH when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; - const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpJwtPath).toBe("env-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); - it("prefers a remote-set auth.jwt_issuer over a conflicting SUPABASE_AUTH_JWT_ISSUER", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; - const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_issuer"]), + it.live("rejects an enabled unmodeled external provider missing secret", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { + auth: { external: { custom: { enabled: true, client_id: "abc" } } }, + }; + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document))) + .message, + ).toContain("Missing required field in config: auth.external.custom.secret"); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.authJwtIssuer).toBe("https://remote.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; - }); - it("still applies SUPABASE_AUTH_JWT_ISSUER when no remote block matched", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; - const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authJwtIssuer).toBe("https://env.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; - }); + it.live("does not require a secret for apple/google providers", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { + auth: { external: { apple: { enabled: true, client_id: "abc" } } }, + }; + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("prefers a remote-set auth.additional_redirect_urls over a conflicting SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; - const config = baseConfig({ - auth: { additional_redirect_urls: ["https://remote.example.com"] }, - }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.additional_redirect_urls"]), + it.live("does not validate a disabled unmodeled external provider", () => + Effect.gen(function* () { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - expect(values.authAdditionalRedirectUrls).toEqual(["https://remote.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; - }); - it("still applies SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS when no remote block matched", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; - const config = baseConfig({ - auth: { additional_redirect_urls: ["https://remote.example.com"] }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authAdditionalRedirectUrls).toEqual(["https://env.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + it.live("skips the check entirely when no document is threaded through", () => + Effect.gen(function* () { + const config = baseConfig(); + expect(yield* resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); }); - describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // rpId/rpOrigins aren't part of the return value, so precedence is proven through - // validateResolvedConfig's own emptiness check on a document that leaves the field present - // but empty. - afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - }); - - it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ID when a remote block already set (empty) auth.webauthn.rp_id", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, - }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_id"]), - ), - ).toThrow("Missing required field in config: auth.webauthn.rp_id"); - }); - - it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ID when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, + describe("resolveAuthSms (top-level scalars)", () => { + it("leaves the scalars at their configured values when nothing is overridden", () => { + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + max_frequency: "5s", + twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(true); + expect(resolved.max_frequency).toBe("5s"); }); + }); - it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when a remote block already set (empty) auth.webauthn.rp_origins", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; - const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, - }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_origins"]), - ), - ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + describe("resolveAuthSms (disables phone login with no provider enabled)", () => { + it("downgrades enable_signup to false when configured true with no provider enabled", () => { + const configured = { ...baseConfig().auth.sms, enable_signup: true }; + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(false); }); - it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; - const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, + it("leaves enable_signup alone when a provider is enabled", () => { + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(true); }); - }); - it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + it("leaves enable_signup at false when already false with no provider enabled", () => { + const resolved = resolveAuthSms(baseConfig().auth.sms); + expect(resolved.enable_signup).toBe(false); }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), - ), - ).not.toThrow(); }); - it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: true, third_party: { firebase: { enabled: false } } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + describe("api.tls (cert/key validation)", () => { + const tempRoot = useTempWorkdir("supabase-api-tls-test-"); - it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["edge_runtime.deno_version"]), - ), - ).not.toThrow(); - }); + const writeTlsFile = (workdir: string, name: string, contents = "dummy") => + Effect.gen(function* () { + const supabaseDir = yield* join(workdir, "supabase"); + yield* mkdirp(supabaseDir); + yield* writeFile(yield* join(supabaseDir, name), contents); + }); - it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: abc.", + it.live("does not throw when tls.enabled with neither cert_path nor key_path set", () => + Effect.gen(function* () { + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.enabled"]), - ), - ).not.toThrow(); - }); + // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read + // behavior below is tested here. - it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', + it.live("throws an error when the configured cert file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read TLS cert: "); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; - const config = baseConfig({ studio: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.enabled"]), - ), - ).not.toThrow(); - }); - it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; - const config = baseConfig({ studio: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', + it.live("throws an error when the configured key file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect( + (yield* Effect.flip(resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current))) + .message, + ).toContain("failed to read TLS key: "); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { - process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; - const config = baseConfig({ studio: { port: 54323 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.port"]), - ), - ).not.toThrow(); - }); - - it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; - const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["local_smtp.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; - const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', + it.live("succeeds when both cert_path and key_path are readable", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; - const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enable_signup"]), - ), - ).not.toThrow(); - }); - it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; - const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', + it.live( + "resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", + () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; - const config = baseConfig({ auth: { minimum_password_length: 8 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.minimum_password_length"]), - ), - ).not.toThrow(); - }); - it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.webhooks.enabled"]), - ), - ).not.toThrow(); + it.live("skips TLS validation entirely when api is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect( + yield* resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).toBeDefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); }); +}); - it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const config = baseConfig({ - auth: { - hook: { - custom_access_token: { - enabled: true, - uri: "https://example.com/hook", - secrets: `v1,whsec_${"A".repeat(32)}`, - }, - }, - }, - }); - const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.hook.custom_access_token.uri"]), - ), - ).not.toThrow(); +describe("narrowConfigEnum", () => { + it("returns a configured value that is one of the allowed values", () => { + expect(narrowConfigEnum("realtime.ip_version", "IPv6", ["IPv4", "IPv6"])).toBe("IPv6"); }); - it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const config = baseConfig({ - auth: { - hook: { - custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, - }, - }, - }); - const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + it("fails with the config value error naming every allowed value", () => { + expect(() => narrowConfigEnum("realtime.ip_version", "v9", ["IPv4", "IPv6"])).toThrow( + new CliConfigValueError({ + path: "realtime.ip_version", + tier: "config", + message: 'Invalid config for realtime.ip_version: "v9" must be one of "IPv4", "IPv6"', + }), + ); }); }); describe("resolveLocalJwks", () => { const tempRoot = useTempWorkdir("supabase-local-jwks-test-"); - it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { - const config = baseConfig(); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, - ], - }); - }); + it.live( + "includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", + () => + Effect.gen(function* () { + const config = baseConfig(); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + expect(yield* decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, + ], + }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("publishes the public form of every signing key and omits the oct fallback", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + it.live("publishes the public form of every signing key and omits the oct fallback", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { keys: ReadonlyArray> }; - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ - kty: "RSA", - kid: "test-rsa-kid", - n: jwk["n"], - e: jwk["e"], - }); - expect(parsed.keys[0]).not.toHaveProperty("d"); - expect(parsed.keys[0]).not.toHaveProperty("p"); - expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); - }); + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ + kty: "RSA", + kid: "test-rsa-kid", + n: jwk["n"], + e: jwk["e"], + }); + expect(parsed.keys[0]).not.toHaveProperty("d"); + expect(parsed.keys[0]).not.toHaveProperty("p"); + expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("preserves a configured signing key's use/ext and filters key_ops to verify-only", async () => { - const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + it.live("preserves a configured signing key's use/ext and filters key_ops to verify-only", () => + Effect.gen(function* () { + const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { keys: ReadonlyArray> }; - expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); - }); + expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const jwks = await runLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - ], - }); - }); + it.live( + "falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const jwks = yield* runLocalJwks(config, tempRoot.current, "a".repeat(32)); + expect(yield* decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + ], + }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("throws a Go-worded error when the signing keys file does not exist", async () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); + it.live("throws an error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect( + (yield* Effect.flip(runLocalJwks(config, tempRoot.current, "a".repeat(32)))).message, + ).toContain("failed to read signing keys: "); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("throws a Go-worded error when the signing keys file is malformed JSON", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to decode signing keys: ", - ); - }); + it.live("throws an error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const supabaseDir = yield* join(tempRoot.current, "supabase"); + yield* mkdirp(supabaseDir); + yield* writeFile(yield* join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect( + (yield* Effect.flip(runLocalJwks(config, tempRoot.current, "a".repeat(32)))).message, + ).toContain("failed to decode signing keys: "); + }).pipe(Effect.provide(BunServices.layer)), + ); describe("auth.third_party", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("rejects an enabled third-party provider missing its required field", async () => { - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); + it.live("rejects an enabled third-party provider missing its required field", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("rejects more than one enabled third-party provider", async () => { - const config = baseConfig({ - auth: { - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, + it.live("rejects more than one enabled third-party provider", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, }, - }, - }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - }); + }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", async () => { - const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; - const issuerUrl = "https://securetoken.google.com/my-project"; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === `${issuerUrl}/.well-known/openid-configuration`) { - return new Response(JSON.stringify({ jwks_uri: `${issuerUrl}/jwks.json` }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === `${issuerUrl}/jwks.json`) { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch: ${url}`); - }); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, + it.live("does not validate third-party providers when auth is disabled", () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; + const issuerUrl = "https://securetoken.google.com/my-project"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === `${issuerUrl}/.well-known/openid-configuration`) { + return Promise.resolve( + new Response(JSON.stringify({ jwks_uri: `${issuerUrl}/jwks.json` }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === `${issuerUrl}/jwks.json`) { + return Promise.resolve( + new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + const config = baseConfig({ + auth: { + enabled: false, + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, }, - }, - }); - const jwksJson = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray<{ kid?: string }> }; - expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); - fetchMock.mockRestore(); - }); - - it('does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', async () => { - const fetchMock = vi.spyOn(globalThis, "fetch"); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { workos: { enabled: true, issuer_url: "" } }, - }, - }); - - const jwksJson = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray }; - - expect(fetchMock).not.toHaveBeenCalled(); - expect(jwks.keys.length).toBeGreaterThan(0); - fetchMock.mockRestore(); - }); - - it("fetches and includes the remote JWKS for an enabled third-party provider", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - const jwks = await runLocalJwks(config, WORKDIR, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - - expect(parsed.keys).toEqual( - expect.arrayContaining([expect.objectContaining({ kid: "remote-key" })]), - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", async () => { - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); - - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Failed to fetch https://issuer.example/.well-known/openid-configuration", - ); - }); - }); - - describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - for (const name of [ - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - "SUPABASE_AUTH_ENABLED", - ]) { - delete process.env[name]; - } - }); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks( - config, - tempRoot.current, - "a".repeat(32), - undefined, - new Set(["auth.signing_keys_path"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); - }); - - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); + }); + const jwksJson = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const jwks = (yield* decodeJson(jwksJson)) as { keys: ReadonlyArray<{ kid?: string }> }; + expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); + fetchMock.mockRestore(); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://remote-issuer.example/.well-known/openid-configuration") { - return new Response( - JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - } - if (url === "https://remote-issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, + it.live( + "does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url", + () => + Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const config = baseConfig({ + auth: { + enabled: false, + third_party: { workos: { enabled: true, issuer_url: "" } }, + }, }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = - "https://env-should-not-win.test"; - const config = baseConfig({ - auth: { - third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, - }, - }); - const jwks = await runLocalJwks( - config, - WORKDIR, - "a".repeat(32), - undefined, - new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); - fetchMock.mockRestore(); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect( - runLocalJwks(config, WORKDIR, "a".repeat(32), undefined, new Set(["auth.enabled"])), - ).resolves.toEqual(expect.any(String)); - }); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); - }); -}); + const jwksJson = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const jwks = (yield* decodeJson(jwksJson)) as { keys: ReadonlyArray }; -describe("resolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; - }); - - it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; - const document = { auth: { external_url: "https://remote.test" } }; - expect(resolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( - "https://remote.test", + expect(fetchMock).not.toHaveBeenCalled(); + expect(jwks.keys.length).toBeGreaterThan(0); + fetchMock.mockRestore(); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - - it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; - const document = { auth: { external_url: "https://configured.test" } }; - expect(resolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); - }); -}); -describe("resolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - const tempRoot = useTempWorkdir("supabase-configured-signing-keys-test-"); - - afterEach(() => { - delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; - delete process.env["SUPABASE_AUTH_ENABLED"]; - }); + it.live("fetches and includes the remote JWKS for an enabled third-party provider", () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://issuer.example/.well-known/openid-configuration") { + return Promise.resolve( + new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === "https://issuer.example/jwks.json") { + return Promise.resolve( + new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const keys = resolveConfiguredSigningKeys( - config, - tempRoot.current, - undefined, - new Set(["auth.signing_keys_path"]), - ); - expect(keys).toHaveLength(1); - expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); - }); + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + const jwks = yield* runLocalJwks(config, WORKDIR, "a".repeat(32)); + const parsed = (yield* decodeJson(jwks)) as { + keys: ReadonlyArray>; + }; - it("still reads the env-overridden path when no remote block matched", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - "failed to read signing keys: ", + expect(parsed.keys).toEqual( + expect.arrayContaining([expect.objectContaining({ kid: "remote-key" })]), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(BunServices.layer)), ); - }); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveConfiguredSigningKeys(config, tempRoot.current, undefined, new Set(["auth.enabled"])), - ).not.toThrow(); - }); + it.live( + "fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", + () => + Effect.gen(function* () { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.reject(new Error("oidc discovery failed")), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + expect( + (yield* Effect.flip(runLocalJwks(config, WORKDIR, "a".repeat(32)))).message, + ).toContain("Failed to fetch https://issuer.example/.well-known/openid-configuration"); + }).pipe(Effect.provide(BunServices.layer)), ); }); }); diff --git a/apps/cli/src/command-internal/go-jwt.ts b/apps/cli/src/command-internal/local-jwt.ts similarity index 83% rename from apps/cli/src/command-internal/go-jwt.ts rename to apps/cli/src/command-internal/local-jwt.ts index 926569386d..03569c25bd 100644 --- a/apps/cli/src/command-internal/go-jwt.ts +++ b/apps/cli/src/command-internal/local-jwt.ts @@ -1,6 +1,6 @@ import { createHmac, createPrivateKey, createSign } from "node:crypto"; import { DateTime } from "effect"; -import { encodeGoJsonCompact } from "./go-json.ts"; +import { encodeHtmlSafeJsonCompact } from "./html-safe-json.ts"; export { DEFAULT_SIGNING_KEY } from "@supabase/stack/defaults"; @@ -34,7 +34,7 @@ export interface Jwk { /** * HS256 signer for the default local-dev `anon`/`service_role` keys. {@link - * generateAsymmetricGoJwt} below covers the RS256/ES256 branch, taken when + * generateAsymmetricLocalJwt} below covers the RS256/ES256 branch, taken when * `auth.signing_keys_path` is configured. * * Does not reuse `@supabase/stack`'s `generateJwt`: that helper uses a different issuer, a @@ -43,17 +43,17 @@ export interface Jwk { * "now". */ -const GO_JWT_ISSUER = "supabase-demo"; -const GO_JWT_FIXED_EXP = 1983812996; +const LOCAL_JWT_ISSUER = "supabase-demo"; +const LOCAL_JWT_FIXED_EXP = 1983812996; function base64UrlEncode(input: string): string { return Buffer.from(input).toString("base64url"); } -export function generateGoJwt(secret: string, role: "anon" | "service_role"): string { +export function generateLocalJwt(secret: string, role: "anon" | "service_role"): string { const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); const payload = base64UrlEncode( - JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + JSON.stringify({ iss: LOCAL_JWT_ISSUER, role, exp: LOCAL_JWT_FIXED_EXP }), ); const data = `${header}.${payload}`; const signature = createHmac("sha256", secret).update(data).digest("base64url"); @@ -61,7 +61,7 @@ export function generateGoJwt(secret: string, role: "anon" | "service_role"): st } /** Asymmetric-JWT expiry: 10 years from now. */ -const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; +const LOCAL_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; function base64UrlToBigInt(value: string): bigint { const hex = Buffer.from(value, "base64url").toString("hex"); @@ -88,7 +88,7 @@ function modInverse(a: bigint, m: bigint): bigint { /** * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) when absent: Node's `createPrivateKey` - * rejects an RSA JWK without them, unlike Go, which derives them lazily from `p`/`q`/`d` before + * rejects an RSA JWK without them, so they are derived from `p`/`q`/`d` before * signing. Returns the key unchanged if all three are already present, or if `d`/`p`/`q` are * missing (an invalid key either way). */ @@ -141,13 +141,11 @@ function assertSupportedKty(jwk: Jwk): void { } /** - * Reproduces `encoding/base64`'s `CorruptInputError` message for each numeric field, since Go's - * unpadded base64 decoder rejects input Node's own JWK importer would silently accept (e.g. a - * `=`-padded coordinate) and sign a token Go could never have produced. + * Produces a `CorruptInputError` message for each numeric field, since unpadded base64 decoding + * rejects input Node's own JWK importer would silently accept (e.g. a `=`-padded coordinate). * - * Checks fields in Go's exact order (EC: x, y, d; RSA: n, e, d, p, q) so the first invalid field - * matches Go's first-failure-wins order. An absent field is skipped, matching Go's zero value - * decoding to zero bytes. + * Checks fields in a fixed order (EC: x, y, d; RSA: n, e, d, p, q) so the first invalid field + * is reported. An absent field is skipped and decodes to zero bytes. */ function assertDecodableJwkNumericFields(jwk: Jwk): void { const assertField = (label: string, value: string | undefined): void => { @@ -197,8 +195,8 @@ function assertKeyMatchesAlgorithm(jwk: Jwk, algorithm: SupportedJwtAlgorithm): * * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA signature is * DER-encoded, not the raw (r‖s) format JWS requires. The header is serialized with - * {@link encodeGoJsonCompact}, not `JSON.stringify`, since a `kid` containing `<`/`>`/`&` must - * HTML-escape to sign the same bytes Go would. + * {@link encodeHtmlSafeJsonCompact}, not `JSON.stringify`, since a `kid` containing `<`/`>`/`&` must + * HTML-escape so the signed bytes are stable. */ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { try { @@ -227,7 +225,7 @@ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } : { alg: algorithm, typ: "JWT" }; - const headerEncoded = base64UrlEncode(encodeGoJsonCompact(header)); + const headerEncoded = base64UrlEncode(encodeHtmlSafeJsonCompact(header)); const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; @@ -249,11 +247,11 @@ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { /** * The RS256/ES256 signing path, used when `auth.signing_keys_path` resolves to a non-empty JWK * array — the first key in the file signs both the anon and service_role tokens. Same claim - * shape as {@link generateGoJwt} (`iss`/`role`/`exp`), except the expiry is 10 years from now. + * shape as {@link generateLocalJwt} (`iss`/`role`/`exp`), except the expiry is 10 years from now. */ -export function generateAsymmetricGoJwt(jwk: Jwk, role: "anon" | "service_role"): string { +export function generateAsymmetricLocalJwt(jwk: Jwk, role: "anon" | "service_role"): string { const expiresAt = Math.floor(DateTime.toEpochMillis(DateTime.nowUnsafe()) / 1000) + - GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; - return signJwtWithJwk(jwk, JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt })); + LOCAL_JWT_ASYMMETRIC_EXPIRY_SECONDS; + return signJwtWithJwk(jwk, JSON.stringify({ iss: LOCAL_JWT_ISSUER, role, exp: expiresAt })); } diff --git a/apps/cli/src/command-internal/go-jwt.unit.test.ts b/apps/cli/src/command-internal/local-jwt.unit.test.ts similarity index 76% rename from apps/cli/src/command-internal/go-jwt.unit.test.ts rename to apps/cli/src/command-internal/local-jwt.unit.test.ts index 0fa779aa94..aeb0b635ac 100644 --- a/apps/cli/src/command-internal/go-jwt.unit.test.ts +++ b/apps/cli/src/command-internal/local-jwt.unit.test.ts @@ -5,11 +5,11 @@ import { describe, expect, it } from "vitest"; import { assertDecodableJwkAlgorithm, - generateAsymmetricGoJwt, - generateGoJwt, + generateAsymmetricLocalJwt, + generateLocalJwt, signJwtWithJwk, type Jwk, -} from "./go-jwt.ts"; +} from "./local-jwt.ts"; const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; @@ -34,16 +34,16 @@ function decodeSegment(segment: string): string { return Buffer.from(segment, "base64url").toString("utf8"); } -describe("generateGoJwt", () => { - it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { - const token = generateGoJwt(SECRET, "anon"); +describe("generateLocalJwt", () => { + it("emits the JWT header (no extra fields, alg before typ)", () => { + const token = generateLocalJwt(SECRET, "anon"); const [header] = token.split("."); expect(header).toBeDefined(); expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); }); - it("emits the anon payload with Go's exact key order and fixed claims", () => { - const token = generateGoJwt(SECRET, "anon"); + it("emits the anon payload with the exact key order and fixed claims", () => { + const token = generateLocalJwt(SECRET, "anon"); const [, payload] = token.split("."); expect(payload).toBeDefined(); const raw = decodeSegment(payload ?? ""); @@ -56,15 +56,15 @@ describe("generateGoJwt", () => { expect(Object.keys(parsed)).not.toContain("is_anonymous"); }); - it("emits the service_role payload with Go's exact key order and fixed claims", () => { - const token = generateGoJwt(SECRET, "service_role"); + it("emits the service_role payload with the exact key order and fixed claims", () => { + const token = generateLocalJwt(SECRET, "service_role"); const [, payload] = token.split("."); const raw = decodeSegment(payload ?? ""); expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); }); it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { - const token = generateGoJwt(SECRET, "anon"); + const token = generateLocalJwt(SECRET, "anon"); const [header, payload, signature] = token.split("."); const expectedSignature = createHmac("sha256", SECRET) .update(`${header}.${payload}`) @@ -73,22 +73,22 @@ describe("generateGoJwt", () => { }); it("is deterministic across calls (no timestamp derived from Date.now())", () => { - const first = generateGoJwt(SECRET, "anon"); - const second = generateGoJwt(SECRET, "anon"); + const first = generateLocalJwt(SECRET, "anon"); + const second = generateLocalJwt(SECRET, "anon"); expect(first).toBe(second); }); it("produces different tokens for different secrets", () => { - const a = generateGoJwt(SECRET, "anon"); - const b = generateGoJwt("a-different-secret-value-1234567", "anon"); + const a = generateLocalJwt(SECRET, "anon"); + const b = generateLocalJwt("a-different-secret-value-1234567", "anon"); expect(a).not.toBe(b); }); }); -describe("generateAsymmetricGoJwt", () => { +describe("generateAsymmetricLocalJwt", () => { it("signs and verifies an RS256 token from an RSA JWK", () => { const jwk = generateRsaJwk("rsa-kid"); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); return importJWK(publicJwkOf(jwk), "RS256") .then((publicKey) => jwtVerify(token, publicKey)) .then(({ payload, protectedHeader }) => { @@ -97,10 +97,10 @@ describe("generateAsymmetricGoJwt", () => { }); }); - it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", () => { + it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi)", () => { const jwk = generateRsaJwk("rsa-kid"); const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; - const token = generateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); + const token = generateAsymmetricLocalJwt(jwkWithoutCrtParams, "anon"); return importJWK(publicJwkOf(jwk), "RS256") .then((publicKey) => jwtVerify(token, publicKey)) .then(({ payload, protectedHeader }) => { @@ -111,7 +111,7 @@ describe("generateAsymmetricGoJwt", () => { it("signs and verifies an ES256 token from an EC JWK", () => { const jwk = generateEcJwk("ec-kid"); - const token = generateAsymmetricGoJwt(jwk, "service_role"); + const token = generateAsymmetricLocalJwt(jwk, "service_role"); return importJWK(publicJwkOf(jwk), "ES256") .then((publicKey) => jwtVerify(token, publicKey)) .then(({ payload, protectedHeader }) => { @@ -122,7 +122,7 @@ describe("generateAsymmetricGoJwt", () => { it("omits the kid header entirely when the JWK has no kid", () => { const jwk = generateRsaJwk(); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); const [header] = token.split("."); const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); @@ -131,7 +131,7 @@ describe("generateAsymmetricGoJwt", () => { it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { const jwk = generateRsaJwk(); const before = Math.floor(DateTime.toEpochMillis(DateTime.nowUnsafe()) / 1000); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); const [, payload] = token.split("."); const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; @@ -141,39 +141,41 @@ describe("generateAsymmetricGoJwt", () => { it("rejects an unsupported algorithm", () => { const jwk = { ...generateRsaJwk(), alg: "RS512" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported algorithm: RS512"); + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow("unsupported algorithm: RS512"); }); it("rejects a JWK with no algorithm", () => { const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); - expect(() => generateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow("unsupported algorithm: "); + expect(() => generateAsymmetricLocalJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); }); it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to sign JWT: key is of invalid type: RSA sign expects *rsa.PrivateKey", ); }); it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { const jwk = { ...generateRsaJwk(), alg: "ES256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to sign JWT: key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey", ); }); - it("rejects an ES256 EC key whose curve is not P-256, wrapped like Go's GenerateAsymmetricJWT", () => { + it("rejects an ES256 EC key whose curve is not P-256, wrapped in the private-key conversion error", () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to convert JWK to private key: unsupported curve: P-384", ); }); - it("rejects a JWK with no kty at all, wrapped like Go's GenerateAsymmetricJWT", () => { + it("rejects a JWK with no kty at all, wrapped in the private-key conversion error", () => { const jwk = { kty: "oct" } as Jwk; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to convert JWK to private key: unsupported key type: oct", ); }); @@ -181,15 +183,15 @@ describe("generateAsymmetricGoJwt", () => { it("rejects an ES256 EC key with no curve at all", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; - expect(() => generateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwkWithoutCurve, "anon")).toThrow( "failed to convert JWK to private key: unsupported curve: ", ); }); - it("rejects a padded EC coordinate instead of signing a token Go would refuse to produce (CLI-1961 Codex review finding)", () => { + it("rejects a padded EC coordinate instead of signing an invalid token", () => { const jwk = generateEcJwk("ec-kid"); const padded = { ...jwk, x: `${jwk.x}=` }; - expect(() => generateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(padded, "anon")).toThrow( /^failed to convert JWK to private key: failed to decode x coordinate: illegal base64 data at input byte \d+$/, ); }); @@ -197,21 +199,21 @@ describe("generateAsymmetricGoJwt", () => { it("rejects a padded RSA modulus the same way", () => { const jwk = generateRsaJwk("rsa-kid"); const padded = { ...jwk, n: `${jwk.n}=` }; - expect(() => generateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(padded, "anon")).toThrow( /^failed to convert JWK to private key: failed to decode modulus: illegal base64 data at input byte \d+$/, ); }); it("still signs successfully for unpadded (correctly-encoded) coordinates", () => { const jwk = generateEcJwk("ec-kid"); - expect(() => generateAsymmetricGoJwt(jwk, "anon")).not.toThrow(); + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).not.toThrow(); }); }); describe("signJwtWithJwk", () => { it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", () => { const jwk = generateEcJwk("ec-kid"); - // Unsorted and containing `&`, which Go's encoder would normally HTML-escape: this function + // Unsorted and containing `&`, which the encoder would normally HTML-escape: this function // must sign exactly the bytes it's given. const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; const token = signJwtWithJwk(jwk, payloadJson); @@ -225,7 +227,7 @@ describe("signJwtWithJwk", () => { }); }); - it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { + it("HTML-escapes the kid in the header, unlike JSON.stringify", () => { const jwk = generateEcJwk("ac&d"); const token = signJwtWithJwk(jwk, '{"role":"anon"}'); const [header] = token.split("."); @@ -245,7 +247,7 @@ describe("assertDecodableJwkAlgorithm", () => { expect(() => assertDecodableJwkAlgorithm(undefined)).not.toThrow(); }); - it("rejects an unsupported algorithm with Go's exact UnmarshalText message", () => { + it("rejects an unsupported algorithm with the allowed-values message", () => { expect(() => assertDecodableJwkAlgorithm("HS256")).toThrow("must be one of [RS256 ES256]"); }); }); diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index b223e10253..57ac81dba7 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -1,173 +1,58 @@ -import { - loadCliProjectEnvironment, - CliConfigSchema, - type LoadedCliConfig, - type CliConfig, -} from "@supabase/config/effect"; -import { loadCliConfig } from "@supabase/config/internal"; -import { - Config, - Console, - Crypto, - Effect, - FileSystem, - Option, - Path, - Predicate, - Schema, -} from "effect"; +import type { CliConfig } from "@supabase/config"; +import { Crypto, Effect, FileSystem, Option, Path } from "effect"; -import { recordOrioleDbTelemetry } from "./db-image.ts"; -import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; -import { getHostname } from "./hostname.ts"; -import { - deprecatedOrioleDBVersionEnvWarning, - envOverride, - envOverrideMajorVersion, - envOverrideOrioleDBVersion, -} from "./local-config-values.ts"; -import { resolveProjectEnvironmentValues } from "./project-environment.ts"; -import { configParseCauseMessage } from "../shared/output/normalize-error.ts"; +import { CliConfigValues, type ResolvedCliConfig } from "../config/cli-config-values.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { + describeConfigLoadFailure, + loadLocalResolvedConfigContext, +} from "./resolved-config-context.ts"; +import { recordOrioleDbTelemetry } from "./db-image.ts"; -/** Config, resolved project env values, hostname, and sanitized project id for a command. */ +/** Effective config, project env file values, hostname, and sanitized project id for a command. */ export interface LocalProjectContext { + /** The decoded config with every override, default and normalizer applied. */ readonly config: CliConfig; - readonly projectEnvValues: Record; - /** `null` when no `supabase/config.toml` was found. */ - readonly loaded: LoadedCliConfig | null; + /** Values from `supabase/.env*` files only; a name the shell sets is never in here. */ + readonly projectEnvValues: Readonly>; + readonly resolvedConfig: ResolvedCliConfig; readonly hostname: string; - /** Sanitized project id; see {@link sanitizeProjectId}. */ + /** The project id sanitized for Docker resource names. */ readonly projectId: string; } export const loadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, - // An already-resolved `--linked`/`--project-ref` value, when the caller has one; merges the - // matching `[remotes.]` block over the base config. Defaults to `undefined` (no remote - // merge) for callers that don't have one yet. + // An already-resolved `--linked`/`--project-ref` value, when the caller has one; it selects + // the matching `[remotes.]` block. `undefined` applies no remote. projectRef?: string, ): Effect.Effect< LocalProjectContext, E, - FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto + FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto | CliConfigValues > => Effect.gen(function* () { - const supabaseEnv = - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_ENV")).pipe( - Effect.mapError(() => - mapConfigLoadError("failed to resolve environment variable: SUPABASE_ENV"), - ), - ), - ) || "development"; - // `workdir` is already the fully-resolved chdir target, so `search: false` stops - // `@supabase/config` from climbing ancestors and picking up an unrelated project's - // config.toml when `workdir` has none of its own. - const projectEnv = yield* loadCliProjectEnvironment({ - cwd: workdir, - baseEnv: process.env, - search: false, - // Omits `.env.local` when `SUPABASE_ENV=test`, matching - // `resolveProjectEnvironmentValues`'s gating for the project-root pass. - skipEnvLocal: supabaseEnv === "test", - }).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - ); - - // Must resolve before `loadCliConfig` decodes config.toml: an `env(...)`-valued `project_id` - // needs these values available to the decoder already. `workdir` is passed through so dotenv - // files under `/supabase` are still discovered even when `projectEnv` is `null`. - const projectEnvValues = yield* resolveProjectEnvironmentValues( - projectEnv, - workdir, - supabaseEnv, - ).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - ); - - // An absent config.toml is not a failure — a project id still resolves from the workdir - // basename default. Only a malformed file is a hard error. - const loaded = yield* loadCliConfig(workdir, { - cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, - search: false, - // Restricts resolution to `supabase/config.toml`; without this, a workdir with a stray - // `config.json` would be preferred over it. - tomlOnly: true, - goViperCompat: true, - projectRef, - }).pipe( - Effect.mapError((cause) => - mapConfigLoadError( - `failed to read config: ${Predicate.isTagged(cause, "CliConfigParseError") ? configParseCauseMessage(cause.cause) : String(cause)}`, - ), - ), - ); - // Printed beside the loader's deprecated-config WARNs, on the same real console, so it shares - // their once-per-context-load cadence. - const orioleDBEnvWarning = yield* deprecatedOrioleDBVersionEnvWarning((name) => - envOverride(name, undefined, projectEnvValues), - ); - if (orioleDBEnvWarning !== undefined) { - yield* Console.error(orioleDBEnvWarning).pipe( - Effect.provideService(Console.Console, globalThis.console), + const { resolvedConfig, config, projectEnvValues, hostname, projectId } = + yield* loadLocalResolvedConfigContext(workdir, Option.fromNullishOr(projectRef)).pipe( + Effect.mapError((cause) => mapConfigLoadError(describeConfigLoadFailure(cause))), ); - } - const config = - loaded?.config ?? - (yield* Schema.decodeEffect(CliConfigSchema)({}).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - )); - const hostname = yield* getHostname().pipe( - Effect.mapError((cause) => - mapConfigLoadError(`failed to resolve hostname: ${cause.message}`), - ), - ); - const path = yield* Path.Path; - // When a `[remotes.]` block matched `projectRef` above, its own `project_id` field is - // what selected it, so a stale or differently-scoped `SUPABASE_PROJECT_ID` must not win over - // it here. - const projectId = sanitizeProjectId( - resolveLocalProjectId( - path, - loaded?.appliedRemote !== undefined - ? undefined - : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_PROJECT_ID")).pipe( - Effect.mapError(() => - mapConfigLoadError( - "failed to resolve environment variable: SUPABASE_PROJECT_ID", - ), - ), - ), - )), - config.project_id, - workdir, - projectRef, - ), - ); + const appliedRemote = Option.getOrUndefined(resolvedConfig.appliedRemote); yield* Effect.annotateCurrentSpan({ - "config.found": loaded !== null, - "config.remote_applied": loaded?.appliedRemote !== undefined, + "config.remote_applied": appliedRemote !== undefined, }); - return { config, projectEnvValues, loaded, hostname, projectId }; + return { + config, + projectEnvValues, + resolvedConfig, + hostname, + projectId, + }; }).pipe(Effect.withSpan("LocalProjectContext.load")); /** Records OrioleDB selection for commands whose only local config read is this context. */ export const recordLocalProjectOrioleDbTelemetry = (context: LocalProjectContext) => - Effect.try(() => ({ - version: envOverrideOrioleDBVersion( - context.config.db.orioledb_version, - context.projectEnvValues, - ), - majorVersion: envOverrideMajorVersion( - context.config.db.major_version, - context.projectEnvValues, - ), - })).pipe( - Effect.flatMap(({ version, majorVersion }) => recordOrioleDbTelemetry(version, majorVersion)), + recordOrioleDbTelemetry(context.config.db.orioledb_version, context.config.db.major_version).pipe( Effect.ignore, ); diff --git a/apps/cli/src/command-internal/local-project-context.unit.test.ts b/apps/cli/src/command-internal/local-project-context.unit.test.ts index 6491930d7c..1b121b4c5e 100644 --- a/apps/cli/src/command-internal/local-project-context.unit.test.ts +++ b/apps/cli/src/command-internal/local-project-context.unit.test.ts @@ -1,20 +1,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { - Config, - ConfigProvider, - Data, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, -} from "effect"; +import { Config, ConfigProvider, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; -import { useTempWorkdir, withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import { loadLocalProjectContext } from "./local-project-context.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { mockOutput, processEnvLayer } from "../../tests/helpers/mocks.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; +import { loadLocalProjectContext } from "./local-project-context.ts"; /** Stands in for the whole Docker-client env-key set, which a project dotenv file never reaches. */ const DOCKER_HOST_KEY = "DOCKER_HOST"; @@ -22,6 +16,8 @@ const DOCKER_HOST_KEY = "DOCKER_HOST"; /** Unlike Docker-client keys, this one is read at container-spawn time, so a project dotenv file can still set it. */ const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; +const REF = "abcdefghijklmnopqrst"; + class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} const writeDotEnv = Effect.fnUntraced(function* (workdir: string, contents: string) { @@ -50,83 +46,89 @@ const processEnvValue = (name: string) => ), ); +/** Runs against a shell environment of exactly `env`, with the real config service. */ +const layerWithShellEnv = (env: Readonly> = {}) => + Layer.fresh( + Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), + ), + ), + ), + processEnvLayer(env), + ), + ); + const tempRoot = useTempWorkdir("supabase-project-context-"); -describe("loadLocalProjectContext", () => { - it.effect( - "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", - () => { - const ref = "abcdefghijklmnopqrst"; - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeConfigToml( - workdir, - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ); +const loadContext = (workdir: string, projectRef?: string) => + loadLocalProjectContext(workdir, (message) => new TestError({ message }), projectRef); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ref, - ); - expect(context.loaded?.appliedRemote).toBe("prod"); - expect(context.projectId).toBe(ref); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "local", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "local" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), +describe("loadLocalProjectContext", () => { + it.effect("prefers SUPABASE_PROJECT_ID over a matched [remotes.]'s project_id", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${REF}"`, ""].join("\n"), ); - }, - ); - it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { - const ref = "abcdefghijklmnopqrst"; + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.some("prod")); + expect(context.projectId).toBe("local"); + }).pipe(Effect.provide(layerWithShellEnv({ SUPABASE_PROJECT_ID: "local" }))); + }); + + it.effect("names the project after the workdir, not the ref, when no project_id is set", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const path = yield* Path.Path; + yield* writeConfigToml(workdir, ""); + + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.none()); + expect(context.projectId).toBe(sanitizeProjectId(path.basename(workdir))); + }).pipe(Effect.provide(layerWithShellEnv())); + }); + + it.effect("applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { const workdir = tempRoot.current; return Effect.gen(function* () { yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ref, - ); - expect(context.loaded?.appliedRemote).toBeUndefined(); + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.none()); expect(context.projectId).toBe("env-project"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "env-project", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "env-project" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv({ SUPABASE_PROJECT_ID: "env-project" }))); }); - it.effect( - "does NOT install a project .env's DOCKER_HOST into process.env, matching Go's Docker client being frozen at binary startup, before godotenv.Load ever runs", - () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + it.effect("does not install a project .env's DOCKER_HOST into process.env", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); - expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBeUndefined(); - }).pipe( - (body) => withEnvVar(DOCKER_HOST_KEY, undefined, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }, - ); + yield* loadContext(workdir); + expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBeUndefined(); + }).pipe(Effect.provide(layerWithShellEnv())); + }); it.effect( - "leaves an already-set shell DOCKER_HOST untouched regardless of a conflicting project .env value", + "leaves an already-set shell DOCKER_HOST untouched by a conflicting project .env", () => { const workdir = tempRoot.current; return Effect.gen(function* () { yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); + yield* loadContext(workdir); expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBe("tcp://real-shell-host:2375"); }).pipe( - (body) => withEnvVar(DOCKER_HOST_KEY, "tcp://real-shell-host:2375", body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(layerWithShellEnv({ [DOCKER_HOST_KEY]: "tcp://real-shell-host:2375" })), ); }, ); @@ -136,148 +138,41 @@ describe("loadLocalProjectContext", () => { return Effect.gen(function* () { yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); + const context = yield* loadContext(workdir); expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe( "/opt/atlassian/pipelines/agent/build", ); expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBeUndefined(); - }).pipe( - (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, undefined, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("keeps the shell Bitbucket marker ahead of the project value", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); - expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBe("/real-shell-clone-dir"); - }).pipe( - (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, "/real-shell-clone-dir", body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("falls back to config.toml's project_id when SUPABASE_PROJECT_ID is empty", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectId).toBe("toml-project"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv())); }); it.effect( - "falls back to config.toml's project_id when only Config sees an empty SUPABASE_PROJECT_ID", + "leaves a shell-set key out of projectEnvValues so the shell value stays the source", () => { - // Windows env lookups are case-insensitive, so a lowercase `supabase_project_id=` reaches the - // Config read with "" while the ambient values only carry the lowercase key. const workdir = tempRoot.current; return Effect.gen(function* () { - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectId).toBe("toml-project"); + const context = yield* loadContext(workdir); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBeUndefined(); + expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBe("/real-shell-clone-dir"); }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(layerWithShellEnv({ [BITBUCKET_CLONE_DIR_KEY]: "/real-shell-clone-dir" })), ); }, ); - it.effect("loads the development dotenv files when SUPABASE_ENV is empty", () => { + it.effect("holds only project .env file values, never host environment variables", () => { const workdir = tempRoot.current; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString( - path.join(workdir, "supabase", ".env.development"), - "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", - ); + yield* writeConfigToml(workdir, ""); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), "FROM_FILE=file-value\n"); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectEnvValues["SUPABASE_AUTH_JWT_SECRET"]).toBe("dev-secret"); - }).pipe( - (body) => withEnvVar("SUPABASE_AUTH_JWT_SECRET", undefined, body), - (body) => withConfigEnv({ SUPABASE_ENV: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("skips an unparseable supabase/.env.local when SUPABASE_ENV is test", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); - - const exit = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }).pipe( - (body) => withConfigEnv({ SUPABASE_ENV: "test" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("fails on an unparseable supabase/.env.local outside the test environment", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); - - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error.message).toMatch(/^failed to read config: /); - }).pipe( - (body) => withConfigEnv({ SUPABASE_ENV: "development" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("names the failing field when supabase/config.toml fails to decode", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeConfigToml(workdir, '[db]\nport = "abc"\n'); - - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error.message).toBe('failed to read config: Expected number\n at ["db"]["port"]'); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))); + const context = yield* loadContext(workdir); + expect(context.projectEnvValues).toEqual({ FROM_FILE: "file-value" }); + }).pipe(Effect.provide(layerWithShellEnv({ FROM_SHELL: "shell-value" }))); }); it.effect("reports a TOML syntax error's position without quoting the file", () => { @@ -288,71 +183,10 @@ describe("loadLocalProjectContext", () => { '[edge_runtime.secrets]\nAPI_KEY = "sk_live_123"\nbroken =\n', ); - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); + const error = yield* loadContext(workdir).pipe(Effect.flip); expect(error.message).toBe( "failed to read config: Invalid TOML document: incomplete declaration: value expected (line 3, column 9)", ); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))); - }); - - it.effect("keeps the Error: prefix when a project-root .env is malformed", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeDotEnv(workdir, "not a valid line\n"); - - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error.message).toMatch( - /^failed to read config: Error: failed to parse environment file: /, - ); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))); - }); - - it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TestError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); - }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.make(() => - Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), - ), - ), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("fails with a typed error when SUPABASE_PROJECT_ID cannot be resolved", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TestError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_PROJECT_ID"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.make((path) => - path[0] === "SUPABASE_PROJECT_ID" - ? Effect.fail(new ConfigProvider.SourceError({ message: "injected" })) - : ConfigProvider.fromEnvRecord({}).load(path), - ), - ), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv())); }); }); diff --git a/apps/cli/src/command-internal/login-crypto.layer.ts b/apps/cli/src/command-internal/login-crypto.layer.ts index 75d6eece0a..54299e5f6f 100644 --- a/apps/cli/src/command-internal/login-crypto.layer.ts +++ b/apps/cli/src/command-internal/login-crypto.layer.ts @@ -27,7 +27,7 @@ export const loginCryptoLayer = Layer.sync(LoginCrypto, () => const host = hostname(); if (user && host) return `cli_${user}@${host}_${ts}`; } catch { - /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ + /* fall through to the fallback name */ } return `cli_${ts}`; }), @@ -35,7 +35,7 @@ export const loginCryptoLayer = Layer.sync(LoginCrypto, () => Effect.try({ try: () => { const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); - // Go's `aesgcm.Open` expects the 16-byte GCM tag appended to the + // The payload carries the 16-byte GCM tag appended to the // ciphertext; Node wants it supplied separately via `setAuthTag`. const ciphertextHex = payload.ciphertext.slice(0, -32); const authTagHex = payload.ciphertext.slice(-32); diff --git a/apps/cli/src/command-internal/management-api-runtime.layer.ts b/apps/cli/src/command-internal/management-api-runtime.layer.ts index 0be3a1706d..419311f577 100644 --- a/apps/cli/src/command-internal/management-api-runtime.layer.ts +++ b/apps/cli/src/command-internal/management-api-runtime.layer.ts @@ -13,9 +13,9 @@ import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { commandPlatformApiLayer } from "../auth/command-platform-api.layer.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; -import { RemoteTarget } from "../config/remote-target.service.ts"; -import { remoteTargetLayer } from "../config/remote-target.layer.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import { projectRefLayer } from "../config/project-ref.layer.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { dohFetchLayer } from "./http-dns.ts"; import { IdentityStitch, identityStitchLayer } from "./identity-stitch.ts"; @@ -68,7 +68,7 @@ export function managementApiRuntimeLayer(subcommand: ReadonlyArray) { httpClient, credentials, cliSettings, - remoteTargetLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), + projectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), linkedProjectCacheLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -113,7 +113,7 @@ type ManagementApiServices = | HttpClient.HttpClient | CommandCredentials | CommandSettings - | RemoteTarget + | ProjectRefResolver | LinkedProjectCache | TelemetryState | CommandRuntime @@ -143,7 +143,7 @@ export function linkedDbResolverRuntimeLayer(subcommand: ReadonlyArray) httpClient, credentials, cliSettings, - remoteTargetLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), + projectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), linkedProjectCacheLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -155,22 +155,6 @@ export function linkedDbResolverRuntimeLayer(subcommand: ReadonlyArray) return built; } -/** Resolves only the hosted target, for commands that delegate to the Go binary. */ -export const remoteTargetRuntimeLayer = (() => { - const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); - const credentials = commandCredentialsLayer.pipe( - Layer.provide(cliSettings), - Layer.provide(debugLoggerLayer), - ); - const platformApiFactory = commandPlatformApiFactoryLayer.pipe( - Layer.provide(credentials), - Layer.provide(cliSettings), - Layer.provide(debugLoggerLayer), - Layer.provide(identityStitchLayer), - ); - return remoteTargetLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)); -})(); - type LinkedDbResolverRuntime = ReturnType; export type LinkedDbResolverRuntimeRequirements = LinkedDbResolverRuntime extends Layer.Layer ? R : never; diff --git a/apps/cli/src/command-internal/migrate-and-seed.ts b/apps/cli/src/command-internal/migrate-and-seed.ts index bae901144d..1bf8470264 100644 --- a/apps/cli/src/command-internal/migrate-and-seed.ts +++ b/apps/cli/src/command-internal/migrate-and-seed.ts @@ -45,6 +45,22 @@ const migrationApplyError = ( }); }; +/** Warns a versionless `--experimental` rebuild that `schema_paths` is ignored while pg-delta is on. */ +export const experimentalSchemaPathsIgnoredWarning = + "WARNING: [db.migrations].schema_paths is not applied while pg-delta is enabled (the default). Set [experimental.pgdelta] enabled = false to apply schema_paths files.\n"; + +/** Whether {@link experimentalSchemaPathsIgnoredWarning} applies to this rebuild. */ +export const ignoresExperimentalSchemaPaths = (inputs: { + readonly experimental: boolean; + readonly version: string; + readonly pgDeltaEnabled: boolean; + readonly schemaPaths: ReadonlyArray; +}): boolean => + inputs.experimental && + inputs.version.length === 0 && + inputs.pgDeltaEnabled && + inputs.schemaPaths.length > 0; + /** * Reapplies local migrations up to `version`, then runs seed files. * @@ -64,6 +80,9 @@ export const migrateAndSeed = ( ) => Effect.gen(function* () { const output = yield* Output; + if (ignoresExperimentalSchemaPaths({ ...config, version })) { + yield* output.raw(experimentalSchemaPathsIgnoredWarning, "stderr"); + } if (config.experimental && version.length === 0 && !config.pgDeltaEnabled) { yield* applySchemaFiles( session, diff --git a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts index 77a9d07a61..705f596254 100644 --- a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts +++ b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts @@ -144,12 +144,14 @@ layer(BunServices.layer)("migrateAndSeed experimental declarative-schema branch" ); expect(execs).toContain("create table schema_marker ()"); expect(execs).not.toContain("create table migration_marker ()"); - expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); + const stderr = out.rawChunks.map((c) => c.text).join(""); + expect(stderr).not.toContain("Applying migration"); + expect(stderr).not.toContain("schema_paths is not applied"); }), ); it.effect( - "falls back to migration files when pg-delta is enabled, even with experimental on and an empty version", + "falls back to migration files and warns that schema_paths is ignored when pg-delta is enabled, even with experimental on and an empty version", () => Effect.gen(function* () { const workdir = yield* makeWorkdir; @@ -175,6 +177,9 @@ layer(BunServices.layer)("migrateAndSeed experimental declarative-schema branch" ); expect(execs).toContain("create table migration_marker ()"); expect(execs).not.toContain("create table schema_marker ()"); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "[db.migrations].schema_paths is not applied while pg-delta is enabled", + ); }), ); @@ -236,6 +241,40 @@ layer(BunServices.layer)("migrateAndSeed experimental declarative-schema branch" }), ); + it.effect( + "warns that schema_paths is ignored even when migrations are disabled and pg-delta is enabled", + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "", + { + ...baseConfig, + migrationsEnabled: false, + experimental: true, + pgDeltaEnabled: true, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).not.toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "[db.migrations].schema_paths is not applied while pg-delta is enabled", + ); + }), + ); + it.effect("still seeds after the declarative-schema branch runs", () => Effect.gen(function* () { const workdir = yield* makeWorkdir; @@ -384,7 +423,7 @@ layer(BunServices.layer)("migrateAndSeed experimental declarative-schema branch" }), ); - it.effect("attaches the failing schema file as Go's CmdSuggestion (See schema file: )", () => + it.effect("attaches the failing schema file as a suggestion (See schema file: )", () => Effect.gen(function* () { const workdir = yield* makeWorkdir; const schemaPath = "supabase/schemas/broken.sql"; diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 197f02cfbb..40eb63121c 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -1,5 +1,6 @@ -import { Config, Data, Effect, Option, type FileSystem, type Path } from "effect"; +import { Data, Effect, type FileSystem, type Path } from "effect"; +import { envValue } from "../shared/config/env-option.ts"; import { Output } from "../shared/output/output.service.ts"; import { bold } from "./colors.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; @@ -18,7 +19,7 @@ import { } from "./migration-history.ts"; import { parseMigrationContent } from "./migration-file.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; -import { splitSqlTokens } from "./sql-split.ts"; +import { splitSqlTokens, trimLeadingSqlComments } from "./sql-split.ts"; /** * A migration file failed to apply. Used by `migration up`/`down`'s migrate-and-seed step; the @@ -39,9 +40,6 @@ export class MigrationApplyError extends Data.TaggedError("MigrationApplyError") } } -// Byte order mark (U+FEFF), stripped from the head of a statement. -const BOM_CODE_POINT = 0xfeff; - // Statements that PostgreSQL refuses to run inside a transaction block / extended-query // pipeline (SQLSTATE 25001). Matched against the upper-cased, comment-stripped statement. // @@ -57,31 +55,6 @@ const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; -/** - * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the - * front of a statement so the keyword check below sees the real first token. - */ -const trimLeadingSqlComments = (sql: string): string => { - // Stripped via code point comparison so it isn't relied on to match a whitespace regex class. - let trimmed = sql.replace(/^[ \t\n\r]+/u, ""); - while (trimmed.charCodeAt(0) === BOM_CODE_POINT) { - trimmed = trimmed.slice(1).replace(/^[ \t\n\r]+/u, ""); - } - for (;;) { - if (trimmed.startsWith("--")) { - const idx = trimmed.indexOf("\n"); - if (idx < 0) return ""; - trimmed = trimmed.slice(idx + 1).replace(/^[ \t\n\r]+/u, ""); - } else if (trimmed.startsWith("/*")) { - const idx = trimmed.indexOf("*/"); - if (idx < 0) return trimmed; - trimmed = trimmed.slice(idx + 2).replace(/^[ \t\n\r]+/u, ""); - } else { - return trimmed.trim(); - } - } -}; - /** * Whether a migration statement cannot run inside a transaction block — `CREATE [UNIQUE] INDEX * CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, @@ -143,14 +116,14 @@ const utf8ByteLength = (value: string): number => new TextEncoder().encode(value // The scanner buffer starts at this size before applying the configured/default max, so a // statement must reach at least this many bytes before an oversized-token error can fire, // regardless of how small an override is set. -const GO_SCANNER_START_BUF_SIZE = 4096; +const SCANNER_START_BUF_SIZE = 4096; // Fallback cap when `SUPABASE_SCANNER_BUFFER_SIZE` is set but parses to a non-positive size, // including a value that can't be parsed at all (e.g. a bare "5M" with no trailing "B"). -const GO_DEFAULT_MAX_SCANNER_CAPACITY = 256 * 1024; +const DEFAULT_MAX_SCANNER_CAPACITY = 256 * 1024; -const GO_MAX_INT64 = 9223372036854775807n; -const GO_MIN_INT64 = -9223372036854775808n; +const INT64_MAX = 9223372036854775807n; +const INT64_MIN = -9223372036854775808n; /** * Parses a base-0 integer literal: decimal, or `0x`/`0o`/`0b`-prefixed hex/octal/binary, or a @@ -158,7 +131,7 @@ const GO_MIN_INT64 = -9223372036854775808n; * the same base (never leading, trailing, or doubled). Returns `undefined` for anything invalid * or outside the 64-bit signed integer range. */ -const parseGoBaseZeroInt = (value: string): number | undefined => { +const parseBaseZeroInt = (value: string): number | undefined => { const negative = value.startsWith("-"); const unsigned = negative || value.startsWith("+") ? value.slice(1) : value; if (unsigned.length === 0) return undefined; @@ -198,7 +171,7 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { const bigPrefix = base === 16 ? "0x" : base === 8 ? "0o" : base === 2 ? "0b" : ""; const magnitude = BigInt(`${bigPrefix}${cleanDigits}`); const signedMagnitude = negative ? -magnitude : magnitude; - if (signedMagnitude > GO_MAX_INT64 || signedMagnitude < GO_MIN_INT64) return undefined; + if (signedMagnitude > INT64_MAX || signedMagnitude < INT64_MIN) return undefined; const n = Number.parseInt(cleanDigits, base); return negative ? -n : n; @@ -206,8 +179,8 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { // Drops a decimal literal's fractional part rather than rounding (`"5.5"` → `"5"`); a // `0x`/`0o`/`0b` literal (which contains letters) never matches and passes through unchanged for -// {@link parseGoBaseZeroInt} to accept or reject. -const trimGoDecimal = (value: string): string => { +// {@link parseBaseZeroInt} to accept or reject. +const trimDecimalFraction = (value: string): string => { if (!value.includes(".")) return value; const match = /^([+-]?\d*)(?:\.\d*)?$/.exec(value); if (!match) return value; @@ -220,7 +193,7 @@ const trimGoDecimal = (value: string): string => { * `SUPABASE_SCANNER_BUFFER_SIZE` accepts an integer byte count, optionally suffixed * `k`/`m`/`g` (× 1024/1024²/1024³) immediately followed by a trailing `b`/`B` (e.g. `"5MB"`). A * bare `"5M"` (no trailing `B`) is NOT 5 MiB — it fails to parse as an integer and is treated as - * unset (`0`), same as any other unparseable or non-positive value. See {@link parseGoBaseZeroInt} + * unset (`0`), same as any other unparseable or non-positive value. See {@link parseBaseZeroInt} * for the accepted integer-literal grammar (hex/octal/binary prefixes, `_` separators). */ const parseScannerBufferSize = (raw: string): number => { @@ -246,7 +219,7 @@ const parseScannerBufferSize = (raw: string): number => { break; } } - const size = parseGoBaseZeroInt(trimGoDecimal(value)); + const size = parseBaseZeroInt(trimDecimalFraction(value)); return size !== undefined && Number.isFinite(size) && size > 0 ? size * multiplier : 0; }; @@ -265,31 +238,27 @@ export const checkScannerBufferSize = ( projectEnv: Readonly> = {}, ): Effect.Effect => Effect.gen(function* () { - const shellValue = yield* Config.option(Config.String("SUPABASE_SCANNER_BUFFER_SIZE")).pipe( - Effect.mapError(() => - mapError("failed to resolve environment variable: SUPABASE_SCANNER_BUFFER_SIZE", "read"), - ), - ); - const raw = Option.getOrUndefined(shellValue) ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; + const shell = yield* envValue("SUPABASE_SCANNER_BUFFER_SIZE"); + const raw = shell ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; if (raw === undefined) return; const configuredLimit = parseScannerBufferSize(raw); // Covers both an explicit non-positive size and an unparseable value (see - // `GO_DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not + // `DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not // to "no limit". const limit = configuredLimit > 0 - ? Math.max(configuredLimit, GO_SCANNER_START_BUF_SIZE) - : GO_DEFAULT_MAX_SCANNER_CAPACITY; - // The reported limit is the raw configured value, even below the `GO_SCANNER_START_BUF_SIZE` + ? Math.max(configuredLimit, SCANNER_START_BUF_SIZE) + : DEFAULT_MAX_SCANNER_CAPACITY; + // The reported limit is the raw configured value, even below the `SCANNER_START_BUF_SIZE` // floor (which only affects when the too-long error can fire, not the number reported), or the // hardcoded default once that's been fallen back to. - const reportedLimit = configuredLimit > 0 ? configuredLimit : GO_DEFAULT_MAX_SCANNER_CAPACITY; + const reportedLimit = configuredLimit > 0 ? configuredLimit : DEFAULT_MAX_SCANNER_CAPACITY; let emitted = 0; let lastRaw = ""; for (const token of splitSqlTokens(content)) { // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an - // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter - // left to find once the buffer fills without one. + // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter left + // to find once the buffer fills without one. const tooLong = token.terminated ? utf8ByteLength(token.raw) > limit : utf8ByteLength(token.raw) >= limit; @@ -297,14 +266,14 @@ export const checkScannerBufferSize = ( const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; return yield* Effect.fail( mapError( - `bufio.Scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, + `scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, "read", ), ); } // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance - // `emitted` — so a lone `;` right before an oversized statement reports it accurately - // instead of a blank token. + // `emitted` — so a lone `;` right before an oversized statement reports it accurately instead + // of a blank token. lastRaw = token.raw; if (token.trimmed.length > 0) { emitted += 1; @@ -314,15 +283,15 @@ export const checkScannerBufferSize = ( /** * Renders a `^` caret line under the error position of a failing statement. `pos` is the - * server's 1-based error cursor, measured in UTF-8 bytes, so multibyte statements shift the caret - * correctly. The caret line replaces every line after the error line. Position 0 (absent), a + * server's 1-based error cursor, measured in characters (code points), not UTF-8 bytes or UTF-16 + * units. The caret line replaces every line after the error line. Position 0 (absent), a * position past the end of the statement, or one landing exactly on a line break leave the * statement untouched. */ export const markError = (stat: string, pos: number): string => { const lines = stat.split("\n"); for (const [j, line] of lines.entries()) { - const c = utf8ByteLength(line); + const c = [...line].length; if (pos > c) { pos -= c + 1; continue; diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index 3119211c83..224e8f2618 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -256,18 +256,16 @@ layer(BunServices.layer)("applyMigrationFile", (it) => { }), ); - it.effect( - "wraps a read failure with Go's parse-file error text (Go NewMigrationFromFile parity)", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "apply-read-fail-" }); - const missingFile = path.join(dir, "20240101120000_missing.sql"); - const { session } = fakeSession(); - const error = yield* run(session, missingFile).pipe(Effect.flip); - expect(error.message).toContain("failed to open migration file: "); - }), + it.effect("wraps a read failure with the parse-file error text", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "apply-read-fail-" }); + const missingFile = path.join(dir, "20240101120000_missing.sql"); + const { session } = fakeSession(); + const error = yield* run(session, missingFile).pipe(Effect.flip); + expect(error.message).toContain("failed to open migration file: "); + }), ); it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => @@ -713,7 +711,7 @@ describe("hasTransactionControl", () => { }); }); -layer(BunServices.layer)("migration failure rendering (Go ExecBatch parity)", (it) => { +layer(BunServices.layer)("migration failure rendering", (it) => { // Error message layout: `\n[Detail]\n[42704 hint]\n` then // `At statement: \n`. const failing = ( @@ -836,10 +834,22 @@ describe("markError", () => { expect(markError("abcde", 5)).toBe("abcde\n ^"); }); - it("consumes the position in UTF-8 bytes like Go, not characters", () => { - expect(markError("héllo\nworld", 8)).toBe("héllo\nworld\n^"); + it("consumes the position in characters, not UTF-8 bytes", () => { + expect(markError("héllo\nworld", 7)).toBe("héllo\nworld\n^"); expect(markError("sélect 1", 3)).toBe("sélect 1\n ^"); }); + + it("places the caret under the error after multibyte text on an earlier line", () => { + expect(markError("insert into notes (body) values ('éééééééé'),\n (foo bar);", 54)).toBe( + "insert into notes (body) values ('éééééééé'),\n (foo bar);\n ^", + ); + }); + + it("counts astral characters as one code point, not two UTF-16 units", () => { + expect(markError("insert into notes (body) values ('😀😀'),\n (foo bar);", 48)).toBe( + "insert into notes (body) values ('😀😀'),\n (foo bar);\n ^", + ); + }); }); describe("isPipelineIncompatible", () => { @@ -982,7 +992,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { }); it.effect( - "reports a read failure with the workdir-relative path, not the absolute path used to read it (Go open supabase/... parity)", + "reports a read failure with the workdir-relative path, not the absolute path used to read it", () => Effect.gen(function* () { // An unreadable file (permission denied, not missing) still passes the glob's own @@ -1007,27 +1017,25 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { }), ); - it.effect( - "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go bufio.Scanner: token too long parity)", - () => - Effect.gen(function* () { - // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured - // limit. - const { workdir } = yield* schemaFile( - "schema-files-scanner-", - "big.sql", - `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, - ); - const { session } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); - expect(error.message).toContain("After statement 1: SELECT 1;"); - expect(error.message).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), + it.effect("rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => + Effect.gen(function* () { + // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured + // limit. + const { workdir } = yield* schemaFile( + "schema-files-scanner-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); + expect(error.message).toContain("After statement 1: SELECT 1;"); + expect(error.message).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), ); it.effect( - "reports the last scanned RAW token in the too-long error even when it trimmed to empty (Go scanner.Text() parity, review CLI-1958)", + "reports the last scanned RAW token in the too-long error even when it trimmed to empty", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1037,13 +1045,13 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); const { session } = fakeSession(); const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("scanner: token too long"); expect(error.message).toContain("After statement 0: ;"); }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), ); it.effect( - "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (Go's default auto-grows to file size)", + "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (the buffer auto-grows to file size)", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1058,7 +1066,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); it.effect( - "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (viper parity, not '5M' == 5MiB)", + "falls back to the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (not '5M' == 5MiB)", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1068,51 +1076,47 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); const { session } = fakeSession(); const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("scanner: token too long"); expect(error.message).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5M" }, body)), ); - it.effect( - "accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 parity, review CLI-1958)", - () => - Effect.gen(function* () { - const { workdir } = yield* schemaFile( - "schema-files-scanner-hex-", - "big.sql", - `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, - ); - const { session } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); - expect(error.message).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0x1400" }, body)), + it.effect("accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE", () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-hex-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0x1400" }, body)), ); - it.effect( - "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 underscore-literal parity, review CLI-1958)", - () => - Effect.gen(function* () { - const { workdir } = yield* schemaFile( - "schema-files-scanner-underscore-", - "big.sql", - `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, - ); - const { session } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); - expect(error.message).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, body)), + it.effect("accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE", () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-underscore-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, body)), ); it.effect( - "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator (Go strconv.ParseInt underscore-grammar parity, review CLI-1958)", + "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1127,7 +1131,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); it.effect( - "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows Go's signed int range (strconv.ParseInt/cast.ToInt range-error parity, review CLI-1958)", + "falls back to the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows the signed int range", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1137,7 +1141,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); const { session } = fakeSession(); const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("scanner: token too long"); expect(error.message).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); @@ -1147,7 +1151,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); it.effect( - "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt range-boundary parity, review CLI-1958)", + "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1164,7 +1168,7 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); it.effect( - "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env (Go loadNestedEnv parity)", + "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( @@ -1176,27 +1180,25 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { const error = yield* applySchema(session, workdir, "supabase/big.sql", { SUPABASE_SCANNER_BUFFER_SIZE: "100b", }).pipe(Effect.flip); - expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("scanner: token too long"); }).pipe(withEmptyConfigEnv), ); - it.effect( - "shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE (Go godotenv 'never overrides' parity)", - () => - Effect.gen(function* () { - const { workdir } = yield* schemaFile( - "schema-files-scanner-shellwins-", - "big.sql", - `SELECT '${"a".repeat(5000)}';\n`, - ); - const { session, calls } = fakeSession(); - // "0" is treated as unset (no check); the shell value must still win over the project - // env's tiny limit. - yield* applySchema(session, workdir, "supabase/big.sql", { - SUPABASE_SCANNER_BUFFER_SIZE: "100b", - }); - expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0" }, body)), + it.effect("shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE", () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-shellwins-", + "big.sql", + `SELECT '${"a".repeat(5000)}';\n`, + ); + const { session, calls } = fakeSession(); + // "0" is treated as unset (no check); the shell value must still win over the project + // env's tiny limit. + yield* applySchema(session, workdir, "supabase/big.sql", { + SUPABASE_SCANNER_BUFFER_SIZE: "100b", + }); + expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0" }, body)), ); it.effect( @@ -1219,20 +1221,19 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { ); it.effect( - "fails with a read-phase error when SUPABASE_SCANNER_BUFFER_SIZE cannot be resolved", + "treats an unresolvable shell SUPABASE_SCANNER_BUFFER_SIZE as unset and falls back to the project env", () => Effect.gen(function* () { const { workdir } = yield* schemaFile( "schema-files-scanner-config-error-", "big.sql", - "SELECT 1;\n", - ); - const { session, calls } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toBe( - "failed to resolve environment variable: SUPABASE_SCANNER_BUFFER_SIZE", + `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, ); - expect(executedSql(calls)).toEqual([]); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql", { + SUPABASE_SCANNER_BUFFER_SIZE: "100b", + }).pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); }).pipe( Effect.provide( ConfigProvider.layer( diff --git a/apps/cli/src/command-internal/migration-checks-policy.ts b/apps/cli/src/command-internal/migration-checks-policy.ts new file mode 100644 index 0000000000..bb8380b174 --- /dev/null +++ b/apps/cli/src/command-internal/migration-checks-policy.ts @@ -0,0 +1,32 @@ +import type { CliConfig } from "@supabase/config"; +import { + DEFAULT_MIGRATION_CHECK_SEVERITY, + MIGRATION_CHECK_RULES, + mapMigrationCheckRules, + type MigrationCheckRule, + type MigrationCheckSeverity, +} from "@supabase/config/internal"; + +type MigrationChecksConfig = CliConfig["db"]["migrations"]["checks"]; + +/** One object a rule skips, written "schema.name". */ +interface MigrationCheckExclusion { + readonly rule: MigrationCheckRule; + readonly object: string; +} + +/** The migration check policy in the shape `@supabase/pg-checks` takes. */ +export interface MigrationChecksPolicy { + readonly severities: { readonly [R in MigrationCheckRule]: MigrationCheckSeverity }; + readonly exclude: ReadonlyArray; +} + +/** Resolves `[db.migrations.checks]`: an unset rule is "warn", exclusions become rule/object pairs. */ +export const resolveMigrationChecksPolicy = ( + checks: MigrationChecksConfig, +): MigrationChecksPolicy => ({ + severities: mapMigrationCheckRules((rule) => checks?.[rule] ?? DEFAULT_MIGRATION_CHECK_SEVERITY), + exclude: MIGRATION_CHECK_RULES.flatMap((rule) => + (checks?.exclude?.[rule] ?? []).map((object) => ({ rule, object })), + ), +}); diff --git a/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts b/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts new file mode 100644 index 0000000000..ec720d8327 --- /dev/null +++ b/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts @@ -0,0 +1,60 @@ +import type { CliConfig } from "@supabase/config"; +import { describe, expect, it } from "vitest"; +import { resolveMigrationChecksPolicy } from "./migration-checks-policy.ts"; + +const warnSeverities = { + rls_disabled_in_public: "warn", + rls_enabled_no_policy: "warn", + policy_exists_rls_disabled: "warn", + security_definer_view: "warn", + rls_policy_always_true: "warn", + sensitive_columns_exposed: "warn", + auth_users_exposed: "warn", + function_search_path_mutable: "warn", + rls_references_user_metadata: "warn", +}; + +describe("resolveMigrationChecksPolicy", () => { + it("defaults all nine rules to warn with no exclusions when checks is absent", () => { + expect(resolveMigrationChecksPolicy(undefined)).toEqual({ + severities: warnSeverities, + exclude: [], + }); + }); + + it("keeps explicit severities and fills only unset rules with warn", () => { + const checks: CliConfig["db"]["migrations"]["checks"] = { + rls_disabled_in_public: "error", + rls_enabled_no_policy: "off", + }; + + expect(resolveMigrationChecksPolicy(checks)).toEqual({ + severities: { + ...warnSeverities, + rls_disabled_in_public: "error", + rls_enabled_no_policy: "off", + }, + exclude: [], + }); + }); + + it("flattens exclusions in rule order while keeping entry order", () => { + const checks: CliConfig["db"]["migrations"]["checks"] = { + exclude: { + function_search_path_mutable: ["public.z_function", "public.a_function"], + security_definer_view: [], + rls_disabled_in_public: ["public.z_table", "public.a_table"], + }, + }; + + expect(resolveMigrationChecksPolicy(checks)).toEqual({ + severities: warnSeverities, + exclude: [ + { rule: "rls_disabled_in_public", object: "public.z_table" }, + { rule: "rls_disabled_in_public", object: "public.a_table" }, + { rule: "function_search_path_mutable", object: "public.z_function" }, + { rule: "function_search_path_mutable", object: "public.a_function" }, + ], + }); + }); +}); diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 2e6c480f8b..45bf6ec894 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -4,30 +4,21 @@ import { DnsResolverFlag, resolveYesWithProjectEnv } from "./global-flags.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; import { Output } from "../shared/output/output.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { RemoteTarget } from "../config/remote-target.service.ts"; import { bold } from "./colors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { DbConnection } from "./db-connection.service.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; -import { type DbTargetSelection } from "./db-target-flags.ts"; +import { loadProjectEnvValues } from "./db-config.toml-read.ts"; +import { announcedTarget, hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import { readMigrationTable } from "./migration-history.ts"; +import { promptYesNo } from "./prompt-yes-no.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { LinkedProjectCache } from "../telemetry/linked-project-cache.service.ts"; -import { - MigrationTargetFlagsError, - OperationCanceledError, -} from "../commands/migration/migration.errors.ts"; -import { migrationConfirm } from "../commands/migration/migration.prompt.ts"; -import type { MigrationFetchFlags } from "../commands/migration/fetch/fetch.command.ts"; +import { OperationCanceledError } from "../commands/migration/migration.errors.ts"; import { MigrationFetchWriteError } from "../commands/migration/fetch/fetch.errors.ts"; -// Re-exported so an in-process caller (`pull`) can build a `MigrationFetchInput` without -// reaching into `commands/migration/**` directly. -export type { MigrationFetchFlags }; - export interface MigrationFetchInput { - readonly flags: MigrationFetchFlags; - readonly target: DbTargetSelection; - /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution. */ + readonly target: ResolvedTarget; + /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for the overwrite prompt. */ readonly assumeYes?: boolean; } @@ -39,7 +30,7 @@ export interface MigrationFetchOutcome { export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( input: MigrationFetchInput, ) { - const { flags, target, assumeYes } = input; + const { target, assumeYes } = input; const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -48,62 +39,30 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Validated first, before any other work — same ordering as `migration down`/`repair`. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }); - } - - const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. - yield* Effect.annotateCurrentSpan("db.conn_type", connType); + yield* Effect.annotateCurrentSpan("db.conn_type", target.kind); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Resolve the DB config BEFORE any filesystem/prompt side effects — an invalid - // `--db-url`/`config.toml` then fails immediately, instead of first creating - // `supabase/migrations` or letting a declined overwrite prompt mask the real error - // with `context canceled`. Same fix as `migration repair`. - const remoteTarget = yield* RemoteTarget; - const linkedTarget = - connType === "linked" ? yield* remoteTarget.load(flags.projectRef) : undefined; - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + // Resolved before any filesystem or prompt side effect so an invalid `--db-url` or + // `config.toml` fails immediately instead of behind a declined overwrite prompt. + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, - target: linkedTarget, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); - // The project .env loads after the parse-time flag-group validation above — so a - // SUPABASE_YES set only in supabase/.env auto-confirms, but a flag conflict still - // surfaces before any .env read. Resolve --yes against the project env here, not - // just process.env. Same ordering as `migration down`/`repair`. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Linked fetch caches the project ref on success. The cache write is attached to the body - // via `Effect.ensuring`, so a declined prompt returns before it runs. + // The cache write is attached to the body so a declined prompt returns before it runs. let cacheLinkedRef: Effect.Effect | undefined; - if (linkedTarget !== undefined) { + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - cacheLinkedRef = linkedProjectCache.cache(linkedTarget.ref); + cacheLinkedRef = linkedProjectCache.cache(target.ref); } const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - yield* fs - .makeDirectory(migrationsDir, { recursive: true }) - .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); // Only a missing directory counts as "empty"; any other read error must propagate — // collapsing it to empty would skip the confirmation and risk clobbering existing migrations. const existing = yield* fs.readDirectory(migrationsDir).pipe( @@ -118,11 +77,15 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( ), ); if (existing.length > 0) { - const title = `Do you want to overwrite existing files in ${bold("supabase/migrations")} directory?`; + const source = Option.match(yield* announcedTarget, { + onNone: () => "the target database", + onSome: bold, + }); + const title = `Overwrite local migrations with the history from ${source}?`; const overwrite = assumeYes !== undefined ? assumeYes - : yield* migrationConfirm(title, { defaultValue: true, yes }); + : yield* promptYesNo(output, yes, title, true, true, { readMachineStdin: true }); if (!overwrite) { return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } @@ -130,12 +93,6 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const migrations = yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -144,6 +101,10 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( }), ); + yield* fs + .makeDirectory(migrationsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); + const written: Array = []; for (const file of migrations) { // The version/name come from the remote `schema_migrations` table. A hostile remote could diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 3ff20c7f21..1089e149f0 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -171,7 +171,7 @@ export type MigrationSync = export function reconcileMigrations( remote: ReadonlyArray, local: ReadonlyArray, - isLocal = false, + targetFlags: string, ): MigrationSync { // `MIGRATION_VERSION_MAX` pins the exhausted side of the walk; `parseMigrationVersion` is // shared with `migration list` so both surfaces skip the same edge-case versions. @@ -216,7 +216,7 @@ export function reconcileMigrations( if (extraRemote.length + extraLocal.length > 0) { return { kind: "conflict", - suggestion: suggestMigrationRepair(extraRemote, extraLocal, isLocal), + suggestion: suggestMigrationRepair(extraRemote, extraLocal, targetFlags), }; } if (local.length === 0) { @@ -225,31 +225,32 @@ export function reconcileMigrations( return { kind: "in-sync" }; } +export const withTargetFlags = (command: string, targetFlags: string): string => + targetFlags === "" ? command : `${command} ${targetFlags}`; + export function suggestMigrationRepair( extraRemote: ReadonlyArray, extraLocal: ReadonlyArray, - isLocal = false, + targetFlags: string, ): string { - const localFlag = isLocal ? " --local" : ""; let result = "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n"; for (const version of extraRemote) { - result += `${bold(`supabase migration repair${localFlag} --status reverted ${version}`)}\n`; + result += `${bold(withTargetFlags(`supabase migration repair --status reverted ${version}`, targetFlags))}\n`; } for (const version of extraLocal) { - result += `${bold(`supabase migration repair${localFlag} --status applied ${version}`)}\n`; + result += `${bold(withTargetFlags(`supabase migration repair --status applied ${version}`, targetFlags))}\n`; } return result; } /** Each generated line ends with a trailing newline, including the last one. */ -export function suggestRevertHistory(versions: ReadonlyArray, isLocal = false): string { - const localFlag = isLocal ? " --local" : ""; +export function suggestRevertHistory(versions: ReadonlyArray, targetFlags: string): string { return ( "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + - `${bold(`supabase migration repair${localFlag} --status reverted ${versions.join(" ")}`)}\n` + + `${bold(withTargetFlags(`supabase migration repair --status reverted ${versions.join(" ")}`, targetFlags))}\n` + "\nAnd update local migrations to match remote database:\n" + - `${bold(`supabase db pull${localFlag}`)}\n` + `${bold(withTargetFlags("supabase db pull", targetFlags))}\n` ); } diff --git a/apps/cli/src/command-internal/migration-history.unit.test.ts b/apps/cli/src/command-internal/migration-history.unit.test.ts index 2c37b61abe..95507b05f2 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -29,64 +29,76 @@ const failingSession = (error: DbExecError): DbSession => ({ describe("reconcileMigrations", () => { it("reports in-sync when remote and local match", () => { - expect(reconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ + expect(reconcileMigrations(["20240101000000"], ["20240101000000"], "--remote")).toEqual({ kind: "in-sync", }); }); it("reports missing only when both histories are empty", () => { - expect(reconcileMigrations([], [])).toEqual({ kind: "missing" }); - expect(reconcileMigrations(["20240101000000"], []).kind).toBe("conflict"); + expect(reconcileMigrations([], [], "--remote")).toEqual({ kind: "missing" }); + expect(reconcileMigrations(["20240101000000"], [], "--remote").kind).toBe("conflict"); }); it("reports a conflict with an extra remote migration", () => { - const result = reconcileMigrations(["20240101000000"], ["20240102000000"]); + const result = reconcileMigrations(["20240101000000"], ["20240102000000"], "--remote"); expect(result.kind).toBe("conflict"); if (result.kind === "conflict") { expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status reverted 20240101000000", + "supabase migration repair --status reverted 20240101000000 --remote", ); expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status applied 20240102000000", + "supabase migration repair --status applied 20240102000000 --remote", ); } }); it("reports a conflict with an extra local migration", () => { - const result = reconcileMigrations([], ["20240102000000"]); + const result = reconcileMigrations([], ["20240102000000"], "--remote"); expect(result.kind).toBe("conflict"); }); it("is in sync when an 8-digit and a 14-digit version share a prefix (#6036)", () => { expect( - reconcileMigrations(["20260420", "20260420010000"], ["20260420010000", "20260420"]), + reconcileMigrations( + ["20260420", "20260420010000"], + ["20260420010000", "20260420"], + "--remote", + ), ).toEqual({ kind: "in-sync" }); }); it("skips versions that do not parse as integers", () => { - expect(reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"])).toEqual({ + expect( + reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"], "--remote"), + ).toEqual({ kind: "in-sync", }); }); - it("skips empty / whitespace versions (matches strconv.Atoi, not Number())", () => { - expect(reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ + it("skips empty / whitespace versions (not Number() coercion)", () => { + expect( + reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"], "--remote"), + ).toEqual({ kind: "in-sync", }); }); - it("treats a version within Go's int64 range as a real conflict (BigInt parity)", () => { - expect(reconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); + it("treats a version within the int64 range as a real conflict", () => { + expect(reconcileMigrations(["9999999999999999"], [], "--remote").kind).toBe("conflict"); }); - it("skips a version beyond Go's int64 range instead of hanging the scan", () => { + it("skips a version beyond the int64 range instead of hanging the scan", () => { expect( - reconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), + reconcileMigrations( + ["20240101000000", "9999999999999999999"], + ["20240101000000"], + "--remote", + ), ).toEqual({ kind: "in-sync" }); }); }); -describe("listRemoteMigrations (suppress only undefined_table, like Go)", () => { +describe("listRemoteMigrations (suppress only undefined_table)", () => { const run = (error: DbExecError) => listRemoteMigrations(failingSession(error)).pipe(Effect.exit); it.effect("treats a missing history table (42P01) as an empty history", () => @@ -129,7 +141,7 @@ describe("listRemoteMigrations (suppress only undefined_table, like Go)", () => ); }); -describe("findPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMismatch)", () => { +describe("findPendingMigrations", () => { it("returns the local paths after the remote count when in sync", () => { const local = ["0", "1", "2"].map(mig); const result = findPendingMigrations(local, ["0"]); @@ -174,25 +186,33 @@ describe("findPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMis }); describe("suggestMigrationRepair", () => { - it("lists reverted (remote) then applied (local) repair commands", () => { - const out = stripAnsi(suggestMigrationRepair(["111"], ["222"])); + it("lists reverted (remote) then applied (local) repair commands for the target", () => { + const out = stripAnsi(suggestMigrationRepair(["111"], ["222"], "--remote")); expect(out).toContain("try repairing the migration history table:"); - expect(out).toContain("supabase migration repair --status reverted 111"); - expect(out).toContain("supabase migration repair --status applied 222"); + expect(out).toContain("supabase migration repair --status reverted 111 --remote"); + expect(out).toContain("supabase migration repair --status applied 222 --remote"); + }); + + it("puts the target last in every repair command, so --remote cannot swallow a version", () => { + expect(stripAnsi(suggestMigrationRepair(["111"], [], "--local"))).toContain( + "supabase migration repair --status reverted 111 --local", + ); + expect(stripAnsi(suggestMigrationRepair([], ["222"], "--remote="))).toContain( + "supabase migration repair --status applied 222 --remote=", + ); }); }); describe("suggestRevertHistory", () => { it("builds the revert-history suggestion with a trailing newline per line", () => { - expect(suggestRevertHistory(["0002", "0003"])).toContain( - "supabase migration repair --status reverted 0002 0003", - ); - expect(suggestRevertHistory(["0002"])).toMatch(/\n$/u); - expect(suggestRevertHistory(["0002"])).toContain("supabase db pull"); + const out = stripAnsi(suggestRevertHistory(["0002", "0003"], "--remote")); + expect(out).toContain("supabase migration repair --status reverted 0002 0003 --remote"); + expect(out).toContain("supabase db pull --remote"); + expect(out).toMatch(/\n$/u); }); }); -describe("resolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { +describe("resolveMigrationFile (byte-ordered match)", () => { it.effect("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", () => { // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP // private-use character (U+E000): JS's default `.sort()` ranks the surrogate pair first — diff --git a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts index c3e1d2114b..47bfd4dcd5 100644 --- a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts @@ -11,7 +11,7 @@ describe("formatTimestampVersion", () => { expect(formatTimestampVersion("20220727064246")).toBe("2022-07-27 06:42:46"); }); - it("passes through short numeric versions (Go time.Parse errors → input)", () => { + it("passes through short numeric versions (parse errors → input)", () => { expect(formatTimestampVersion("0")).toBe("0"); expect(formatTimestampVersion("1")).toBe("1"); }); @@ -20,7 +20,7 @@ describe("formatTimestampVersion", () => { expect(formatTimestampVersion("abc")).toBe("abc"); }); - it("passes through out-of-range months/days like Go's strict parse", () => { + it("passes through out-of-range months/days", () => { expect(formatTimestampVersion("20221327064246")).toBe("20221327064246"); // month 13 expect(formatTimestampVersion("20220230000000")).toBe("20220230000000"); // Feb 30 expect(formatTimestampVersion("20220727256046")).toBe("20220727256046"); // hour 25 @@ -42,7 +42,7 @@ describe("parseMigrationVersion", () => { expect(parseMigrationVersion("0")).toBe(0n); }); - it("rejects non-integer versions like Go's strconv.Atoi", () => { + it("rejects non-integer versions", () => { expect(parseMigrationVersion("")).toBeUndefined(); expect(parseMigrationVersion("abc")).toBeUndefined(); expect(parseMigrationVersion(" 12")).toBeUndefined(); @@ -51,17 +51,17 @@ describe("parseMigrationVersion", () => { expect(parseMigrationVersion("+")).toBeUndefined(); }); - it("accepts a leading +/- sign like strconv.Atoi (for malformed history rows)", () => { - // Go's `Atoi` == `ParseInt(s, 10, 0)` accepts a leading sign, so `migration repair -1` + it("accepts a leading +/- sign (for malformed history rows)", () => { + // A leading sign is accepted, so `migration repair -1` // can delete a malformed `-1` row and the two-pointer merge orders `-1` before `0`. expect(parseMigrationVersion("-1")).toBe(-1n); expect(parseMigrationVersion("+5")).toBe(5n); }); it("accepts the full int64 range but rejects values outside it", () => { - // 16 digits Go accepts and surfaces as a conflict — must NOT be skipped. + // 16 digits parse and surface as a conflict — must NOT be skipped. expect(parseMigrationVersion("9999999999999999")).toBe(9999999999999999n); - // int64 max/min parse; one beyond either bound is rejected (Go's Atoi range error). + // int64 max/min parse; one beyond either bound is rejected. expect(parseMigrationVersion(MIGRATION_VERSION_MAX.toString())).toBe(MIGRATION_VERSION_MAX); expect(parseMigrationVersion((MIGRATION_VERSION_MAX + 1n).toString())).toBeUndefined(); expect(parseMigrationVersion("-9223372036854775808")).toBe(-9223372036854775808n); diff --git a/apps/cli/src/command-internal/go-output-flag.ts b/apps/cli/src/command-internal/output-formats.ts similarity index 89% rename from apps/cli/src/command-internal/go-output-flag.ts rename to apps/cli/src/command-internal/output-formats.ts index 8e6ad00f44..52d4e85d8b 100644 --- a/apps/cli/src/command-internal/go-output-flag.ts +++ b/apps/cli/src/command-internal/output-formats.ts @@ -20,7 +20,7 @@ export const QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; /** * Raised when `-o`/`--output` carries a value the active command doesn't accept. The message - * matches the established pflag rejection format: `invalid argument %q for %q flag: %v`. + * has the form `invalid argument "" for "" flag: `. */ export class InvalidOutputFormatError extends Data.TaggedError("InvalidOutputFormatError")<{ readonly message: string; @@ -36,8 +36,8 @@ export function outputFormatEnumMessage(allowed: ReadonlyArray): string } /** - * The full rejection message for an invalid `-o` value, matching the established - * `invalid argument %q for %q flag: %v` pflag format. + * The full rejection message for an invalid `-o` value, in the + * `invalid argument "" for "" flag: ` format. */ export function invalidOutputFormatMessage(value: string, allowed: ReadonlyArray): string { return `invalid argument "${value}" for "-o, --output" flag: ${outputFormatEnumMessage(allowed)}`; diff --git a/apps/cli/src/command-internal/go-output-flag.unit.test.ts b/apps/cli/src/command-internal/output-formats.unit.test.ts similarity index 84% rename from apps/cli/src/command-internal/go-output-flag.unit.test.ts rename to apps/cli/src/command-internal/output-formats.unit.test.ts index 41b440f43f..d39cc59273 100644 --- a/apps/cli/src/command-internal/go-output-flag.unit.test.ts +++ b/apps/cli/src/command-internal/output-formats.unit.test.ts @@ -5,10 +5,10 @@ import { invalidOutputFormatMessage, outputFormatEnumMessage, unsupportedOutputFlagMessage, -} from "./go-output-flag.ts"; +} from "./output-formats.ts"; -describe("go-output-flag", () => { - it("joins the allowed set with Go's ` | ` bracket format", () => { +describe("output-formats", () => { + it("joins the allowed set with the ` | ` bracket format", () => { expect(outputFormatEnumMessage(RESOURCE_OUTPUT_FORMATS)).toBe( "must be one of [ env | pretty | json | toml | yaml ]", ); @@ -17,8 +17,7 @@ describe("go-output-flag", () => { ); }); - it("reproduces Go's pflag rejection message byte-for-byte", () => { - // pflag: `invalid argument %q for %q flag: %v`, shorthand-prefixed `-o, --output`. + it("formats the rejection message with the shorthand-prefixed flag name", () => { expect(invalidOutputFormatMessage("table", RESOURCE_OUTPUT_FORMATS)).toBe( 'invalid argument "table" for "-o, --output" flag: must be one of [ env | pretty | json | toml | yaml ]', ); diff --git a/apps/cli/src/command-internal/go-output.encoders.ts b/apps/cli/src/command-internal/output.encoders.ts similarity index 78% rename from apps/cli/src/command-internal/go-output.encoders.ts rename to apps/cli/src/command-internal/output.encoders.ts index 8a026f7816..c7adfb14ec 100644 --- a/apps/cli/src/command-internal/go-output.encoders.ts +++ b/apps/cli/src/command-internal/output.encoders.ts @@ -1,17 +1,17 @@ import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; -import { encodeGoJsonCompact, encodeGoJsonIndented } from "./go-json.ts"; -import { goStringCompare } from "./go-struct-output.encoders.ts"; +import { encodeHtmlSafeJsonCompact, encodeHtmlSafeJsonIndented } from "./html-safe-json.ts"; +import { compareByCodepoint } from "./struct-output.encoders.ts"; /** - * Reproduces `json.Encoder` output for `-o json`: alphabetical key order, Go string escaping, and + * Encodes `-o json` output: alphabetical key order, HTML-safe string escaping, and * a trailing newline. * * `nullForEmptyArrays` re-substitutes `null` for an empty array at the listed keys, for a schema * that decodes both `null` and `[]` to `[]` upstream (e.g. `backups list`'s `"backups": null`). */ -export function encodeGoJson( +export function encodeSortedJson( value: T, options?: { readonly nullForEmptyArrays?: ReadonlyArray }, ): string { @@ -33,24 +33,24 @@ export function encodeGoJson( } source = patched; } - return encodeGoJsonIndented(sortKeysDeep(source)); + return encodeHtmlSafeJsonIndented(sortKeysDeep(source)); } function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value === null || typeof value !== "object") return value; // A plain object reorders integer-like string keys ("2", "10") into ascending numeric order on - // enumeration; building a `Map` instead carries a lexicographic sort through to `go-json.ts`'s + // enumeration; building a `Map` instead carries a lexicographic sort through to `html-safe-json.ts`'s // `walk` intact, since `Map` iteration order is true insertion order. const sorted = new Map(); - // JS's default string sort orders by UTF-16 code unit, which diverges from Go's byte/codepoint + // JS's default string sort orders by UTF-16 code unit, which diverges from byte/codepoint // order once an astral character (a UTF-16 surrogate pair) meets a high-BMP one. - // `goStringCompare` reproduces Go's real map-key order instead — this matters here since `gen + // `compareByCodepoint` sorts by byte/codepoint order instead — this matters here since `gen // bearer-jwt`'s custom claims flow through this same sort before signing. - for (const key of Object.keys(value as Record).sort(goStringCompare)) { + for (const key of Object.keys(value as Record).sort(compareByCodepoint)) { const child = (value as Record)[key]; - // Drop `undefined` properties here, matching `JSON.stringify`'s behavior (the Go-faithful - // walker below would otherwise render them as `null`). + // Drop `undefined` properties here, matching `JSON.stringify`'s behavior (the walker + // below would otherwise render them as `null`). if (child === undefined) continue; sorted.set(key, sortKeysDeep(child)); } @@ -58,27 +58,27 @@ function sortKeysDeep(value: unknown): unknown { } /** - * Serializes an outbound API request body with sorted keys, Go string escaping, no indentation, - * and no trailing newline, matching `json.Marshal`'s struct output. + * Serializes an outbound API request body with sorted keys, HTML-safe string escaping, no + * indentation, and no trailing newline. * - * {@link encodeGoJson} is the parallel for human-facing `--output json`. + * {@link encodeSortedJson} is the parallel for human-facing `--output json`. */ -export function encodeGoStructJsonBody(value: unknown): string { - return encodeGoJsonCompact(sortKeysDeep(value)); +export function encodeSortedJsonBody(value: unknown): string { + return encodeHtmlSafeJsonCompact(sortKeysDeep(value)); } /** * YAML for map payloads (`branches get` envs, `sso info`, `status`, `postgres-config`). Struct - * payloads must use `encodeGoYaml` in `go-struct-output.encoders.ts` instead, since Go's yaml.v3 - * derives keys from Go field names, not JSON tags. + * payloads must use `encodeStructYaml` in `struct-output.encoders.ts` instead, since YAML keys + * derive from struct field names, not JSON tags. */ export function encodeYaml(value: unknown): string { return stringifyYaml(value); } /** - * TOML for map payloads. Struct payloads must use `encodeGoToml` in - * `go-struct-output.encoders.ts` instead, since BurntSushi emits PascalCase Go field names with + * TOML for map payloads. Struct payloads must use `encodeStructToml` in + * `struct-output.encoders.ts` instead, since the struct output uses PascalCase field names with * 2-space table indentation. */ export function encodeToml(value: unknown): string { @@ -90,7 +90,7 @@ export function encodeToml(value: unknown): string { } /** - * Reproduces the established `godotenv.Marshal` byte shape for `--output env`. + * Emits the established dotenv byte shape for `--output env`. * * Nested maps flatten to dotted paths, then uppercase with `.` replaced by `_`. Flattening does * not descend into slices: an array value becomes a single empty-string leaf. Integer-parseable diff --git a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts b/apps/cli/src/command-internal/output.encoders.unit.test.ts similarity index 81% rename from apps/cli/src/command-internal/go-output.encoders.unit.test.ts rename to apps/cli/src/command-internal/output.encoders.unit.test.ts index 98738ed362..f43e55018f 100644 --- a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/output.encoders.unit.test.ts @@ -1,7 +1,7 @@ import { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "vitest"; -import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./go-output.encoders.ts"; +import { encodeEnv, encodeSortedJson, encodeToml, encodeYaml } from "./output.encoders.ts"; // Shaped like the backups response because the `nullForEmptyArrays` byte-parity assertions were // extracted from that port; see the `{ items, name }` fixtures below for plain-object coverage. @@ -23,9 +23,9 @@ const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { }, }; -describe("encodeGoJson", () => { - it("emits Go's alphabetical struct-field order and trailing newline for a populated response", () => { - const out = encodeGoJson(SAMPLE_RESPONSE, { nullForEmptyArrays: ["backups"] }); +describe("encodeSortedJson", () => { + it("emits alphabetical struct-field order and trailing newline for a populated response", () => { + const out = encodeSortedJson(SAMPLE_RESPONSE, { nullForEmptyArrays: ["backups"] }); expect(out).toBe( `{ "backups": [ @@ -49,7 +49,7 @@ describe("encodeGoJson", () => { }); it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { - const out = encodeGoJson( + const out = encodeSortedJson( { region: "ap-southeast-1", walg_enabled: false, @@ -72,7 +72,7 @@ describe("encodeGoJson", () => { }); it("leaves arrays intact when nullForEmptyArrays is not provided", () => { - const out = encodeGoJson({ items: [], name: "x" }); + const out = encodeSortedJson({ items: [], name: "x" }); expect(out).toBe( `{ "items": [], @@ -83,7 +83,7 @@ describe("encodeGoJson", () => { }); it("does not substitute null for non-empty arrays even when listed in nullForEmptyArrays", () => { - const out = encodeGoJson({ items: [1, 2], name: "x" }, { nullForEmptyArrays: ["items"] }); + const out = encodeSortedJson({ items: [1, 2], name: "x" }, { nullForEmptyArrays: ["items"] }); expect(out).toBe( `{ "items": [ @@ -96,8 +96,8 @@ describe("encodeGoJson", () => { ); }); - it("keeps Go's true lexicographic order for numeric-looking keys (CLI-1961 Codex review finding)", () => { - const out = encodeGoJson({ 10: "a", 2: "b", role: "anon" }); + it("keeps true lexicographic order for numeric-looking keys", () => { + const out = encodeSortedJson({ 10: "a", 2: "b", role: "anon" }); expect(out).toBe( `{ "10": "a", @@ -108,10 +108,10 @@ describe("encodeGoJson", () => { ); }); - it("sorts keys by Go's byte/code-point order, not JS's UTF-16 code-unit order (CLI-1961 Codex review finding)", () => { + it("sorts keys by byte/code-point order, not JS's UTF-16 code-unit order", () => { const highBmp = String.fromCodePoint(0xe000); const astral = String.fromCodePoint(0x10000); - const out = encodeGoJson({ [astral]: 2, [highBmp]: 1 }); + const out = encodeSortedJson({ [astral]: 2, [highBmp]: 1 }); expect(out).toBe(`{\n "${highBmp}": 1,\n "${astral}": 2\n}\n`); }); }); @@ -145,21 +145,21 @@ describe("encodeEnv", () => { expect(lines).toContain('PITR_ENABLED="true"'); }); - it("emits integer-parseable values unquoted (matches godotenv strconv.Atoi branch)", () => { + it("emits integer-parseable values unquoted", () => { const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain("PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000"); expect(lines).toContain("PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000"); }); - it("collapses arrays to a single empty leaf (Go viper does not descend into slices)", () => { + it("collapses arrays to a single empty leaf", () => { const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain('BACKUPS=""'); expect(lines.some((line) => line.startsWith("BACKUPS_0_"))).toBe(false); }); - it("matches Go's full env output for the sample backup response", () => { + it("emits the full env output for the sample backup response", () => { expect(encodeEnv(SAMPLE_RESPONSE)).toBe( [ 'BACKUPS=""', @@ -177,7 +177,7 @@ describe("encodeEnv", () => { expect(out).toBe('MESSAGE="with \\"quotes\\" and \\\\backslash"'); }); - it("escapes embedded newlines, carriage returns, and tabs (Go %q parity)", () => { + it("escapes embedded newlines, carriage returns, and tabs", () => { const out = encodeEnv({ description: "line one\nline two\rwith\ttab" }); expect(out).toBe('DESCRIPTION="line one\\nline two\\rwith\\ttab"'); }); @@ -187,11 +187,11 @@ describe("encodeEnv", () => { expect(out.split("\n")).toEqual(["A=2", "M=3", "Z=1"]); }); - it("omits empty nested maps entirely (Go viper parity)", () => { + it("omits empty nested maps entirely", () => { expect(encodeEnv({ physical_backup_data: {} })).toBe(""); }); - it("matches Go for the PITR-only response shape with empty physical_backup_data", () => { + it("handles the PITR-only response shape with empty physical_backup_data", () => { expect( encodeEnv({ region: "ap-southeast-1", diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 9bac16fb00..d1e9c05b30 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -1,18 +1,91 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../config/command-settings.service.ts"; +import { PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; +import { type CachedLinkedProject, readLinkSnapshotSoft } from "../shared/config/link-snapshot.ts"; + +export type ParentRefResolution = + | { + readonly kind: "resolved"; + readonly ref: string; + /** The linked ref when it differs from the recorded parent; the parent must list it. */ + readonly linkedRef?: string; + } + | { readonly kind: "invalid" } + | { readonly kind: "absent" }; + +/** The parent project the checkout's link records; any read failure is `None`. */ +export const readCachedLinkedProject = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): Effect.Effect> => + readLinkSnapshotSoft(fs, path, workdir).pipe(Effect.map((snapshot) => snapshot.parent)); + +function classifyParentCandidates( + candidates: ReadonlyArray>, +): ParentRefResolution { + // The first present candidate decides: a malformed higher-priority one stays invalid rather than + // falling through to a lower-priority project that a mutation could then hit by mistake. + for (const candidate of candidates) { + if (Option.isSome(candidate)) { + return PROJECT_REF_PATTERN.test(candidate.value) + ? { kind: "resolved", ref: candidate.value } + : { kind: "invalid" }; + } + } + return { kind: "absent" }; +} + /** - * A value made entirely of lowercase letters (but not 20 of them, or it would already have - * been treated as a ref) is a plausible ref typo. Appended to both {@link parentNotLinkedMessage} - * and `link`'s own branch-not-found message. + * The linked parent project ref, not the branch's own ref that `resolve` returns right after + * `link `: `SUPABASE_PROJECT_ID`, then the link's recorded parent, then the linked ref. */ +export const resolveLinkedParentRef = Effect.fn("ParentProjectRef.resolveLinked")(function* () { + const cliSettings = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const snapshot = yield* readLinkSnapshotSoft(fs, path, cliSettings.workdir); + const linkedRef = snapshot.ref; + + // The recorded parent alone isn't proof a link completed — legacy `link` writes it on both + // success and failure — so it counts only when the linked ref confirms the link. + const cachedRef = Option.isSome(linkedRef) + ? Option.map(snapshot.parent, (parent) => parent.ref) + : Option.none(); + + // An env ref equal to the valid linked ref adds no parent information; drop it so the recorded + // parent wins. Pattern-valid only, so a garbage env equal to a garbage link stays invalid. + const envRef = + Option.isSome(cliSettings.projectId) && + Option.isSome(linkedRef) && + cliSettings.projectId.value === linkedRef.value && + PROJECT_REF_PATTERN.test(cliSettings.projectId.value) + ? Option.none() + : cliSettings.projectId; + + const parent = classifyParentCandidates([envRef, cachedRef, linkedRef]); + if (parent.kind !== "resolved" || Option.isSome(envRef) || Option.isNone(cachedRef)) + return parent; + const branchRef = Option.filter( + linkedRef, + (ref) => ref !== parent.ref && PROJECT_REF_PATTERN.test(ref), + ); + return Option.isSome(branchRef) ? { ...parent, linkedRef: branchRef.value } : parent; +}); + +/** A hint for a value that looks like a mistyped project ref; empty otherwise. */ export function parentRefTypoHint(value: string): string { - if (!/^[a-z]+$/.test(value)) return ""; - return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; + if (/^[a-z]+$/.test(value)) { + return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; + } + if (/^[A-Za-z]{20}$/.test(value)) { + return `\n If you meant a project ref: refs are exactly 20 lowercase letters (try "${value.toLowerCase()}").`; + } + return ""; } -/** - * Shared "no project is linked to search for branches" message, produced when - * `RemoteTarget.parent` reports `"absent"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff`. - */ +/** The branch-name lookup error when no project is linked, shared by `link` and `config diff`. */ export function parentNotLinkedMessage(value: string): string { return ( `Cannot resolve "${value}": it is not a project ref (refs are exactly 20 lowercase letters, ` + @@ -23,11 +96,7 @@ export function parentNotLinkedMessage(value: string): string { ); } -/** - * Shared "the linked project ref is invalid" message, produced when - * `RemoteTarget.parent` reports `"invalid"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff`. - */ +/** The branch-name lookup error for an invalid linked ref, shared by `link` and `config diff`. */ export function parentRefInvalidMessage(value: string): string { return `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref `; } diff --git a/apps/cli/src/command-internal/path-containment.ts b/apps/cli/src/command-internal/path-containment.ts index 5c23981748..1359d05a6e 100644 --- a/apps/cli/src/command-internal/path-containment.ts +++ b/apps/cli/src/command-internal/path-containment.ts @@ -4,10 +4,9 @@ * `content_path` values and the `supabase/notebooks/` directory. */ -import { lstatSync, readlinkSync, realpathSync, type Stats } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { Effect, FileSystem, Option, Path, type PlatformError, Predicate, Result } from "effect"; -// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when +// `readLink` bypasses the OS's own `ELOOP` symlink-cycle detection when // manually following a dangling/unsearchable/looping symlink one hop at a // time (see `canonicalizeExistingPath` below), so that manual follow needs // its own explicit bound. @@ -20,84 +19,94 @@ const MAX_SYMLINK_FOLLOW_DEPTH = 40; * happens to start with two dots (e.g. `..templates`) is a distinct, * in-root path and must not be rejected. */ -export function isPathContainedInRoot(root: string, candidatePath: string): boolean { - const rel = relative(root, candidatePath); - return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); +export function isPathContainedInRoot( + path: Path.Path, + root: string, + candidatePath: string, +): boolean { + const rel = path.relative(root, candidatePath); + return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`)); } /** - * Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so + * Canonicalizes `path` when it exists (per `realPath`/`readLink`), or returns `undefined` so * {@link canonicalPathForContainment} keeps walking up to an existing ancestor. * - * `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), + * `realPath` can fail for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), * so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH}, * and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical - * path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path + * path so a loop is rejected rather than accepted. A `readLink` failure unrelated to the path * itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root * path behind a restricted ancestor isn't falsely rejected. */ -function canonicalizeExistingPath(path: string, depth: number): string | undefined { - try { - return realpathSync(path); - } catch { - // Wraps only the `lstatSync` call, not the recursive canonicalization below it: including - // that would let a deep throw there return the outer symlink's own lexically-in-root path, - // turning a rejection into an accept. - let entry: Stats | undefined; - try { - entry = lstatSync(path, { throwIfNoEntry: false }); - } catch { - return undefined; +const canonicalizeExistingPath = Effect.fnUntraced(function* (path: string, depth: number) { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const canonical = yield* fs.realPath(path).pipe(Effect.option); + if (Option.isSome(canonical)) return canonical.value; + /* `FileSystem` has no `lstat`, so `readLink` stands in: it succeeds only for a symlink, and a + symlink whose own mode denies reading it fails with `EACCES` under a searchable parent. */ + const link = yield* fs.readLink(path).pipe(Effect.result); + const isSymbolicLink = + Result.isSuccess(link) || + (Predicate.isTagged(link.failure.reason, "PermissionDenied") && + (yield* fs.stat(`${pathService.dirname(path)}${pathService.sep}.`).pipe(Effect.isSuccess))); + if (isSymbolicLink) { + if (depth < MAX_SYMLINK_FOLLOW_DEPTH) { + const target = yield* Effect.fromResult(link); + return yield* canonicalPathForContainment( + pathService.isAbsolute(target) + ? target + : pathService.join(pathService.dirname(path), target), + depth + 1, + ); } - if (entry === undefined) return undefined; - if (entry.isSymbolicLink()) { - if (depth < MAX_SYMLINK_FOLLOW_DEPTH) { - const target = readlinkSync(path); - return canonicalPathForContainment( - isAbsolute(target) ? target : join(dirname(path), target), - depth + 1, - ); - } - // Must not be treated as "doesn't exist": returning `undefined` here would let the - // ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it - // instead of failing closed. - return path; - } - // A non-symlink entry `lstat` can see but `realpath` can't resolve — e.g. a chmod-000 - // directory on Darwin, whose realpath(3) needs search permission on itself, not just its - // parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing - // path, since a plain entry can't recurse into a loop. - return undefined; + // Must not be treated as "doesn't exist": returning `undefined` here would let the + // ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it + // instead of failing closed. + return path; } -} + // A non-symlink entry that exists but `realPath` can't resolve — e.g. a chmod-000 + // directory on Darwin, whose realpath(3) needs search permission on itself, not just its + // parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing + // path, since a plain entry can't recurse into a loop. + return undefined; +}); /** * Canonicalizes `path` for the containment check, tolerating a path (or an ancestor of it) * that genuinely doesn't exist yet — the normal case for a missing template file or a * notebooks directory a pull is about to create, which should surface as a missing-file error, * not a containment error. Walks up to the deepest existing ancestor, resolves it with - * `realpathSync` (dereferencing any symlinks, including a symlinked project root itself), then + * `realPath` (dereferencing any symlinks, including a symlinked project root itself), then * re-appends the missing tail lexically. The walk-up is iterative, not recursive, so it stays * correct against a pathologically long chain of missing ancestors; each ancestor still goes * through {@link canonicalizeExistingPath}, so an intermediate dangling/unsearchable/looping * symlink is followed rather than lexically skipped. */ -export function canonicalPathForContainment(path: string, depth = 0): string { - const canonical = canonicalizeExistingPath(path, depth); - if (canonical !== undefined) return canonical; +export const canonicalPathForContainment: ( + path: string, + depth?: number, +) => Effect.Effect = + Effect.fnUntraced(function* (path: string, depth = 0) { + const pathService = yield* Path.Path; + const canonical = yield* canonicalizeExistingPath(path, depth); + if (canonical !== undefined) return canonical; - const tail: string[] = [basename(path)]; - let current = dirname(path); - for (;;) { - const ancestorCanonical = canonicalizeExistingPath(current, depth); - if (ancestorCanonical !== undefined) { - return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical); - } - const parent = dirname(current); - if (parent === current) { - return resolve(tail.reduceRight((acc, name) => join(acc, name), current)); + const tail: string[] = [pathService.basename(path)]; + let current = pathService.dirname(path); + for (;;) { + const ancestorCanonical = yield* canonicalizeExistingPath(current, depth); + if (ancestorCanonical !== undefined) { + return tail.reduceRight((acc, name) => pathService.join(acc, name), ancestorCanonical); + } + const parent = pathService.dirname(current); + if (parent === current) { + return pathService.resolve( + tail.reduceRight((acc, name) => pathService.join(acc, name), current), + ); + } + tail.push(pathService.basename(current)); + current = parent; } - tail.push(basename(current)); - current = parent; - } -} + }); diff --git a/apps/cli/src/command-internal/path-match.ts b/apps/cli/src/command-internal/path-match.ts index 58898ffcb1..9258219760 100644 --- a/apps/cli/src/command-internal/path-match.ts +++ b/apps/cli/src/command-internal/path-match.ts @@ -1,11 +1,11 @@ /** - * Matches a glob pattern against a path the way Go's `path.Match` does, for the seed-file - * globber's `[db.seed] sql_paths` expansion. Hand-ported rather than compiled to a `RegExp`, + * Matches a glob pattern against a path using shell-pattern semantics (`*` and `?` never cross + * `/`), for the seed-file globber's `[db.seed] sql_paths` expansion. Hand-ported rather than compiled to a `RegExp`, * since glob character classes diverge from JS regex classes and a malformed pattern must * report `badPattern` instead of being silently reinterpreted. * * Byte-wise (UTF-8), not UTF-16 code units: the `*`-retry loop can land mid-character, whose - * byte decodes as a single invalid rune that a `?` in the retried chunk can then consume. + * byte decodes as a single invalid code point that a `?` in the retried chunk can then consume. */ /** `badPattern` reports a malformed pattern instead of throwing. */ @@ -28,7 +28,7 @@ const UTF8_ENCODER = new TextEncoder(); */ type Bytes = Uint8Array; -const RUNE_ERROR = 0xfffd; +const REPLACEMENT_CHAR = 0xfffd; const SLASH = 0x2f; const STAR = 0x2a; const QUESTION = 0x3f; @@ -38,19 +38,19 @@ const CARET = 0x5e; const HYPHEN = 0x2d; const BACKSLASH = 0x5c; -interface DecodedRune { +interface DecodedCodePoint { readonly r: number; readonly size: number; } /** - * Decodes the UTF-8 rune starting at byte offset `i` of `b`. Any invalid or truncated - * sequence decodes as `(RUNE_ERROR, 1)` — never throws, and never consumes more than the + * Decodes the UTF-8 code point starting at byte offset `i` of `b`. Any invalid or truncated + * sequence decodes as `(REPLACEMENT_CHAR, 1)` — never throws, and never consumes more than the * single invalid lead byte. */ -const decodeRune = (b: Bytes, i: number): DecodedRune => { +const decodeCodePoint = (b: Bytes, i: number): DecodedCodePoint => { const n = b.length - i; - if (n <= 0) return { r: RUNE_ERROR, size: 0 }; + if (n <= 0) return { r: REPLACEMENT_CHAR, size: 0 }; const b0 = b[i]!; if (b0 < 0x80) return { r: b0, size: 1 }; let size: number; @@ -87,17 +87,17 @@ const decodeRune = (b: Bytes, i: number): DecodedRune => { } else { // 0x80-0xC1: a bare continuation byte or an overlong 2-byte lead. 0xF5-0xFF: past // the max valid lead byte. Both are invalid lead bytes. - return { r: RUNE_ERROR, size: 1 }; + return { r: REPLACEMENT_CHAR, size: 1 }; } - if (n < size) return { r: RUNE_ERROR, size: 1 }; + if (n < size) return { r: REPLACEMENT_CHAR, size: 1 }; const b1 = b[i + 1]!; - if (b1 < lo || b1 > hi) return { r: RUNE_ERROR, size: 1 }; + if (b1 < lo || b1 > hi) return { r: REPLACEMENT_CHAR, size: 1 }; if (size === 2) return { r: ((b0 & 0x1f) << 6) | (b1 & 0x3f), size: 2 }; const b2 = b[i + 2]!; - if (b2 < 0x80 || b2 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + if (b2 < 0x80 || b2 > 0xbf) return { r: REPLACEMENT_CHAR, size: 1 }; if (size === 3) return { r: ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f), size: 3 }; const b3 = b[i + 3]!; - if (b3 < 0x80 || b3 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + if (b3 < 0x80 || b3 > 0xbf) return { r: REPLACEMENT_CHAR, size: 1 }; return { r: ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), size: 4, @@ -150,9 +150,9 @@ const getEsc = (chunk: Bytes): GetEsc => { c = c.subarray(1); if (c.length === 0) return { r: 0, rest: c, bad: true }; } - const { r, size } = decodeRune(c, 0); + const { r, size } = decodeCodePoint(c, 0); // A genuinely invalid byte, not a literal (valid, 3-byte-encoded) U+FFFD character. - if (r === RUNE_ERROR && size === 1) return { r, rest: c.subarray(1), bad: true }; + if (r === REPLACEMENT_CHAR && size === 1) return { r, rest: c.subarray(1), bad: true }; const rest = c.subarray(size); return { r, rest, bad: rest.length === 0 }; }; @@ -181,7 +181,7 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { if (op === LBRACKET) { let r = 0; if (!failed) { - const decoded = decodeRune(s, 0); + const decoded = decodeCodePoint(s, 0); r = decoded.r; s = s.subarray(decoded.size); } @@ -215,7 +215,7 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { } else if (op === QUESTION) { if (!failed) { if (s[0] === SLASH) failed = true; - const { size } = decodeRune(s, 0); + const { size } = decodeCodePoint(s, 0); s = s.subarray(size); } chunk = chunk.subarray(1); @@ -239,8 +239,7 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { }; /** - * Reports whether `name` matches the shell pattern `pattern`, using Go's `path.Match` - * semantics. `badPattern` is set (instead of throwing) for a malformed pattern. + * Reports whether `name` matches the shell pattern `pattern`. `badPattern` is set (instead of throwing) for a malformed pattern. */ export const pathMatch = (pattern: string, name: string): PathMatchResult => { let pat = UTF8_ENCODER.encode(pattern); diff --git a/apps/cli/src/command-internal/path-match.unit.test.ts b/apps/cli/src/command-internal/path-match.unit.test.ts index 6d0f8ab3d4..d8e60c9d41 100644 --- a/apps/cli/src/command-internal/path-match.unit.test.ts +++ b/apps/cli/src/command-internal/path-match.unit.test.ts @@ -18,7 +18,7 @@ describe("pathMatch", () => { }); }); - describe("`*` and `?` never cross `/` (Go's non-`/` rule)", () => { + describe("`*` and `?` never cross `/`", () => { it.each([ ["*.sql", "sub/seed.sql", false], ["seed?.sql", "seed/.sql", false], @@ -40,14 +40,14 @@ describe("pathMatch", () => { expect(pathMatch(pattern, name).matched).toBe(expected); }); - it("treats a leading `!` as a literal class member, NOT negation (Go parity)", () => { + it("treats a leading `!` as a literal class member, NOT negation", () => { expect(pathMatch("[!a].sql", "!.sql").matched).toBe(true); expect(pathMatch("[!a].sql", "a.sql").matched).toBe(true); expect(pathMatch("[!a].sql", "b.sql").matched).toBe(false); }); }); - describe("byte-offset `*` retry against multibyte characters (Go path.Match parity)", () => { + describe("byte-offset `*` retry against multibyte characters", () => { it.each([ // U+FF01 (fullwidth `!`) is 3 UTF-8 bytes; U+1F600 (😀) is 4 UTF-8 bytes. ["*??.sql", "!.sql", true], @@ -70,7 +70,7 @@ describe("pathMatch", () => { }); }); - describe("malformed patterns report badPattern (Go's path.ErrBadPattern)", () => { + describe("malformed patterns report badPattern", () => { it.each([ "[", // unterminated class "[a", // unterminated class with member diff --git a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts index 37b85cfeb6..469e0199b2 100644 --- a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Layer, Option, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import type { Stack } from "@supabase/stack/effect"; import { streamPgDumpWithClient, transformManagedDumpLine } from "./pg-dump.run.ts"; +import { dumpRoleScript } from "./pg-dump.scripts.ts"; +import { expandScript } from "./pg-dump.env.ts"; import { NetworkIdFlag } from "./global-flags.ts"; import { DockerRun } from "./docker-run.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; @@ -10,7 +13,7 @@ import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { selection } from "../../tests/helpers/pins.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -const stackFixture = (output: string) => { +const stackFixture = (output: string | ReadonlyArray) => { const calls: Array<{ readonly args: ReadonlyArray; readonly command: string }> = []; const stack = { commands: { @@ -23,86 +26,170 @@ const stackFixture = (output: string) => { ) => Effect.gen(function* () { calls.push({ command: _tool.command, args: options.args ?? [] }); - yield* options.stdout(new TextEncoder().encode(output)); + yield* Effect.forEach( + typeof output === "string" ? [new TextEncoder().encode(output)] : output, + options.stdout, + { discard: true }, + ); return { jobId: "job", exitCode: 0 }; }), }, } as unknown as Stack; - return { stack, calls }; + const layer = Layer.mergeAll( + BunServices.layer, + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(RuntimeInfo, { + cwd: "/tmp", + platform: "linux", + arch: "x64", + homeDir: "/tmp", + execPath: "/usr/bin/bun", + pid: 1, + }), + Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), + Layer.succeed(DockerRun, { + run: () => Effect.die("unused"), + runCapture: () => Effect.die("unused"), + runStream: () => Effect.die("unused"), + }), + mockOutput().layer, + ); + return { stack, calls, layer }; }; describe("managed pg_dump runner", () => { - it("keeps the managed dump filters equivalent to the CLI scripts", () => { - const cases = [ - [ - 'CREATE TABLE "public" (id integer);', - "pg_dump --schema-only", - {}, - 'CREATE TABLE IF NOT EXISTS "public" (id integer);', - ], - ["\\unrestrict 123", "pg_dump --data-only", {}, "-- \\unrestrict 123"], - ["--user-data", "pg_dump --data-only", { EXTRA_SED: "/^--/d" }, "--user-data"], - ["\\restrict 123", "pg_dumpall --roles-only", { EXTRA_SED: "/^--/d" }, undefined], - [ - 'CREATE ROLE "postgres";', - "pg_dumpall --roles-only", - { RESERVED_ROLES: "postgres" }, - '-- CREATE ROLE "postgres";', - ], - [ - 'ALTER ROLE "postgres" SET "statement_timeout" TO "5s";', - "pg_dumpall --roles-only", - { ALLOWED_CONFIGS: "statement_timeout" }, - 'ALTER ROLE "postgres" SET "statement_timeout" TO "5s";', - ], - [ - '-- ALTER ROLE "postgres" SET "pgrst.db_schemas" TO "public";', - "pg_dumpall --roles-only", - { ALLOWED_CONFIGS: "pgrst.*", EXTRA_SED: "/^--/d" }, - 'ALTER ROLE "postgres" SET "pgrst.db_schemas" TO "public";', - ], - [ - 'GRANT "member" TO "dashboard_user";', - "pg_dumpall --roles-only", - { RESERVED_ROLES: "dashboard_user", EXTRA_SED: "/^--/d" }, - undefined, - ], - [ - 'CREATE EXTENSION IF NOT EXISTS "pg_tle" WITH SCHEMA "pgtle";', - "pg_dump --schema-only", - {}, - 'CREATE EXTENSION IF NOT EXISTS "pg_tle";', - ], - [ - 'GRANT SELECT ON TABLE "public"."storage" TO "anon";', - "pg_dump --schema-only", - { EXCLUDED_SCHEMAS: "storage" }, - 'GRANT SELECT ON TABLE "public"."storage" TO "anon";', - ], - [ - 'REVOKE SELECT ON TABLE "storage" FROM "anon";', - "pg_dump --schema-only", - { EXCLUDED_SCHEMAS: "storage" }, - '-- REVOKE SELECT ON TABLE "storage" FROM "anon";', - ], - [ - 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "authenticated";', - "pg_dump --schema-only", - {}, - 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "authenticated";', - ], - [ - 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "postgres" WITH GRANT OPTION;', - "pg_dump --schema-only", - {}, - '-- GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "postgres" WITH GRANT OPTION;', - ], - ] as const; + const cases = [ + [ + 'CREATE TABLE "public" (id integer);', + "pg_dump --schema-only", + {}, + 'CREATE TABLE IF NOT EXISTS "public" (id integer);', + ], + ["\\unrestrict 123", "pg_dump --data-only", {}, "-- \\unrestrict 123"], + ["--user-data", "pg_dump --data-only", { EXTRA_SED: "/^--/d" }, "--user-data"], + ["\\restrict 123", "pg_dumpall --roles-only", { EXTRA_SED: "/^--/d" }, undefined], + [ + 'CREATE ROLE "postgres";', + "pg_dumpall --roles-only", + { RESERVED_ROLES: "postgres" }, + '-- CREATE ROLE "postgres";', + ], + [ + 'ALTER ROLE "postgres" SET "statement_timeout" TO "5s";', + "pg_dumpall --roles-only", + { ALLOWED_CONFIGS: "statement_timeout" }, + 'ALTER ROLE "postgres" SET "statement_timeout" TO "5s";', + ], + [ + '-- ALTER ROLE "postgres" SET "pgrst.db_schemas" TO "public";', + "pg_dumpall --roles-only", + { ALLOWED_CONFIGS: "pgrst.*", EXTRA_SED: "/^--/d" }, + 'ALTER ROLE "postgres" SET "pgrst.db_schemas" TO "public";', + ], + [ + 'GRANT "member" TO "dashboard_user";', + "pg_dumpall --roles-only", + { RESERVED_ROLES: "dashboard_user", EXTRA_SED: "/^--/d" }, + undefined, + ], + [ + 'GRANT SET ON PARAMETER "log_min_messages" TO "supabase_realtime_admin";', + "pg_dumpall --roles-only", + { RESERVED_ROLES: "supabase_realtime_admin" }, + '-- GRANT SET ON PARAMETER "log_min_messages" TO "supabase_realtime_admin";', + ], + [ + 'GRANT ALL ON PARAMETER "log_min_messages" TO "supabase_admin" WITH GRANT OPTION;', + "pg_dumpall --roles-only", + { RESERVED_ROLES: "supabase_.*" }, + '-- GRANT ALL ON PARAMETER "log_min_messages" TO "supabase_admin" WITH GRANT OPTION;', + ], + [ + 'GRANT SET ON PARAMETER "log_min_messages" TO "app_user";', + "pg_dumpall --roles-only", + { RESERVED_ROLES: "supabase_.*" }, + 'GRANT SET ON PARAMETER "log_min_messages" TO "app_user";', + ], + [ + `ALTER ROLE "app_user" SET "myapp.restore_sql" TO 'GRANT SET ON PARAMETER "log_min_messages" TO "postgres";';`, + "pg_dumpall --roles-only", + { RESERVED_ROLES: "postgres" }, + `ALTER ROLE "app_user" SET "myapp.restore_sql" TO 'GRANT SET ON PARAMETER "log_min_messages" TO "postgres";';`, + ], + [ + 'CREATE EXTENSION IF NOT EXISTS "pg_tle" WITH SCHEMA "pgtle";', + "pg_dump --schema-only", + {}, + 'CREATE EXTENSION IF NOT EXISTS "pg_tle";', + ], + [ + 'GRANT SELECT ON TABLE "public"."storage" TO "anon";', + "pg_dump --schema-only", + { EXCLUDED_SCHEMAS: "storage" }, + 'GRANT SELECT ON TABLE "public"."storage" TO "anon";', + ], + [ + 'REVOKE SELECT ON TABLE "storage" FROM "anon";', + "pg_dump --schema-only", + { EXCLUDED_SCHEMAS: "storage" }, + '-- REVOKE SELECT ON TABLE "storage" FROM "anon";', + ], + [ + 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "authenticated";', + "pg_dump --schema-only", + {}, + 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "authenticated";', + ], + [ + 'GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "postgres" WITH GRANT OPTION;', + "pg_dump --schema-only", + {}, + '-- GRANT ALL ON FOREIGN DATA WRAPPER "wrapper" TO "postgres" WITH GRANT OPTION;', + ], + ] as const; + it("keeps the managed dump filters equivalent to the CLI scripts", () => { for (const [input, script, env, expected] of cases) expect(transformManagedDumpLine(input, script, env)).toBe(expected); }); + it.live.skipIf(process.platform === "win32")("filters role dumps through the CLI script", () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const [input, script, env, expected] of cases) { + if (!script.includes("pg_dumpall")) continue; + const child = yield* spawner.spawn( + ChildProcess.make( + "bash", + ["-c", `pg_dumpall() { printf '%s\\n' "$DUMP_INPUT"; }\n${dumpRoleScript}`], + { + env: { + PGHOST: "127.0.0.1", + PGPORT: "5432", + PGUSER: "postgres", + PGPASSWORD: "", + PGDATABASE: "postgres", + RESERVED_ROLES: "", + ALLOWED_CONFIGS: "", + EXTRA_SED: "", + ...env, + DUMP_INPUT: input, + }, + stdin: "ignore", + stderr: "inherit", + }, + ), + ); + const [exitCode, stdout] = yield* Effect.all( + [child.exitCode, Stream.mkString(Stream.decodeText(child.stdout))], + { concurrency: "unbounded" }, + ); + expect(exitCode).toBe(0); + expect(stdout).toBe(`${expected === undefined ? "" : `${expected}\n`}RESET ALL;\n`); + } + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("keeps data dump filters and quoted extra flags in the Stack tool arguments", () => { const fixture = stackFixture("\\unrestrict 123\n"); return streamPgDumpWithClient({ @@ -118,27 +205,7 @@ describe("managed pg_dump runner", () => { onStdout: () => Effect.void, client: { kind: "stack", stack: fixture.stack, command: "pg_dump", pin: selection.database }, }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - Layer.succeed(NetworkIdFlag, Option.none()), - Layer.succeed(RuntimeInfo, { - cwd: "/tmp", - platform: "linux", - arch: "x64", - homeDir: "/tmp", - execPath: "/usr/bin/bun", - pid: 1, - }), - Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), - Layer.succeed(DockerRun, { - run: () => Effect.die("unused"), - runCapture: () => Effect.die("unused"), - runStream: () => Effect.die("unused"), - }), - mockOutput().layer, - ), - ), + Effect.provide(fixture.layer), Effect.tap(() => Effect.sync(() => { expect(fixture.calls[0]?.args).toContain("--exclude-table"); @@ -176,27 +243,7 @@ describe("managed pg_dump runner", () => { }), client: { kind: "stack", stack: fixture.stack, command: "pg_dump", pin: selection.database }, }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - Layer.succeed(NetworkIdFlag, Option.none()), - Layer.succeed(RuntimeInfo, { - cwd: "/tmp", - platform: "linux", - arch: "x64", - homeDir: "/tmp", - execPath: "/usr/bin/bun", - pid: 1, - }), - Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), - Layer.succeed(DockerRun, { - run: () => Effect.die("unused"), - runCapture: () => Effect.die("unused"), - runStream: () => Effect.die("unused"), - }), - mockOutput().layer, - ), - ), + Effect.provide(fixture.layer), Effect.asVoid, Effect.tap(() => Effect.sync(() => { @@ -212,3 +259,133 @@ describe("managed pg_dump runner", () => { ); }); }); + +describe("role dump parameter grants", () => { + const comment = "-- A comment with an unmatched quote: '\n"; + const settings = `ALTER ROLE "app'role" SET "pgrst.db_schemas" TO 'public'; +ALTER ROLE "app_user" SET "pgrst.multiline_sql" TO 'SELECT 1; +GRANT SET ON PARAMETER "log_min_messages" TO "postgres"; +SELECT 2;'; +ALTER ROLE "app_user" SET "pgrst.escaped_sql" TO E'backslash\\\\''quote'' +GRANT SET ON PARAMETER "log_min_messages" TO "postgres"; +last line'; +`; + const reservedGrant = 'GRANT SET ON PARAMETER "log_min_messages" TO "supabase_realtime_admin";'; + const customGrant = 'GRANT SET ON PARAMETER "log_lock_waits" TO "app_user";'; + const quotedCustomGrant = 'GRANT SET ON PARAMETER "log_lock_waits" TO "postgres""custom";'; + const input = `${comment}${settings}${reservedGrant}\n${customGrant}\n${quotedCustomGrant}\n`; + + for (const keepComments of [false, true]) { + const env = { + PGHOST: "unused", + PGPORT: "5432", + PGUSER: "postgres", + PGPASSWORD: "", + PGDATABASE: "postgres", + RESERVED_ROLES: "postgres|supabase_.*", + ALLOWED_CONFIGS: "pgrst.*", + EXTRA_SED: keepComments ? "" : "/^--/d", + }; + const expected = `${keepComments ? comment : ""}${settings}${keepComments ? `-- ${reservedGrant}\n` : ""}${customGrant}\n${quotedCustomGrant}\nRESET ALL;\n`; + + it.live( + `preserves quoted settings across Stack chunks with keep-comments=${keepComments}`, + () => { + const bytes = new TextEncoder().encode(input); + const fixture = stackFixture( + Array.from({ length: Math.ceil(bytes.length / 7) }, (_, i) => + bytes.slice(i * 7, (i + 1) * 7), + ), + ); + return Effect.gen(function* () { + let output = ""; + const result = yield* streamPgDumpWithClient({ + image: "unused", + script: dumpRoleScript, + env, + onStdout: (chunk) => + Effect.sync(() => { + output += new TextDecoder().decode(chunk); + }), + client: { + kind: "stack", + stack: fixture.stack, + command: "pg_dumpall", + pin: selection.database, + }, + }); + expect(result.exitCode).toBe(0); + expect(output).toBe(expected); + }).pipe(Effect.provide(fixture.layer)); + }, + ); + + describe.skipIf(process.platform === "win32")("Bash script", () => { + const longSetting = `ALTER ROLE "app_user" SET "myapp.large" TO '${"x".repeat(512 * 1024)}';\n`; + for (const dryRun of [false, true]) { + it.live( + `preserves long quoted settings with keep-comments=${keepComments}, dry-run=${dryRun}`, + () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const script = dryRun ? expandScript(dumpRoleScript, env) : dumpRoleScript; + const child = yield* spawner.spawn( + ChildProcess.make("bash", ["-c", `pg_dumpall() { cat; }\n${script}`], { + env, + stdin: Stream.make(new TextEncoder().encode(`${longSetting}${input}`)), + stderr: "inherit", + }), + ); + const [exitCode, output] = yield* Effect.all( + [child.exitCode, Stream.mkString(Stream.decodeText(child.stdout))], + { concurrency: "unbounded" }, + ); + expect(exitCode).toBe(0); + expect(output).toBe(`${longSetting}${expected}`); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live( + `preserves role setting bytes with keep-comments=${keepComments}, dry-run=${dryRun}`, + () => + Effect.gen(function* () { + const encoder = new TextEncoder(); + const setting = Uint8Array.from([ + ...encoder.encode('ALTER ROLE "app_user" SET "myapp.bytes" TO \'café'), + 0xff, + ...encoder.encode("';\n"), + ]); + const bytes = Uint8Array.from([...setting, ...encoder.encode(`${reservedGrant}\n`)]); + const expectedBytes = Uint8Array.from([ + ...setting, + ...encoder.encode(`${keepComments ? `-- ${reservedGrant}\n` : ""}RESET ALL;\n`), + ]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const script = dryRun ? expandScript(dumpRoleScript, env) : dumpRoleScript; + /* Keep sed byte-oriented so its existing locale behavior cannot mask awk's. */ + const child = yield* spawner.spawn( + ChildProcess.make( + "bash", + ["-c", `pg_dumpall() { cat; }\nsed() { LC_ALL=C command sed "$@"; }\n${script}`], + { + env: { + ...env, + LC_ALL: process.platform === "darwin" ? "en_US.UTF-8" : "C.UTF-8", + }, + stdin: Stream.make(bytes), + stderr: "inherit", + }, + ), + ); + const [exitCode, chunks] = yield* Effect.all( + [child.exitCode, Stream.runCollect(child.stdout)], + { concurrency: "unbounded" }, + ); + expect(exitCode).toBe(0); + expect(Uint8Array.from(chunks.flatMap((chunk) => [...chunk]))).toEqual(expectedBytes); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + } + }); + } +}); diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index 8232be1e4a..6b248ce3a1 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -7,7 +7,7 @@ type StackRuntimePreference = | { readonly kind: "container"; readonly engine: "docker" | "podman" }; import { NetworkIdFlag } from "./global-flags.ts"; -import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; +import { supabaseEnvStringWithProjectFallback } from "./supabase-env.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DockerRun } from "./docker-run.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; @@ -212,7 +212,7 @@ export const dumpNetworkMode = Effect.fnUntraced(function* ( ): Effect.fn.Return<{ readonly _tag: "named"; readonly name: string } | { readonly _tag: "host" }> { if (networkId !== undefined && networkId.length > 0) return { _tag: "named", name: networkId }; if (forceHostNetwork) return { _tag: "host" }; - const envNetworkId = yield* viperEnvStringWithProjectFallback( + const envNetworkId = yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", projectEnvValues, ); @@ -232,6 +232,7 @@ const transformDumpLine = ( line: string, script: string, env: Readonly>, + inQuotedSql = false, ): string | undefined => { const applyExtraSed = (value: string | undefined): string | undefined => env["EXTRA_SED"] === "/^--/d" && value?.startsWith("--") ? undefined : value; @@ -249,6 +250,13 @@ const transformDumpLine = ( return dropComments(allowedConfig.test(line) ? line : `-- ${line}`); if (new RegExp(`GRANT ".*" TO "(${reservedRoles})"`, "u").test(line)) return dropComments(`-- ${line}`); + if ( + !inQuotedSql && + new RegExp(`^GRANT [A-Z ,]+ ON PARAMETER ".*" TO "(${reservedRoles})"([ ;]|$)`, "u").test( + line, + ) + ) + return dropComments(`-- ${line}`); if (line.startsWith("-- ") && allowedConfig.test(line.slice(3))) return dropComments(line.slice(3)); if (line.startsWith("-- ") && line.includes(" GRANT ")) return dropComments(line); @@ -304,6 +312,25 @@ const stackDumpStdout = ( const encoder = new TextEncoder(); let carry = ""; let previous: string | undefined; + let quote = ""; + const transformLine = (line: string) => { + const inQuotedSql = quote !== ""; + // pg_dumpall doubles quotes in literals and identifiers, including E-strings. + if (script.includes("pg_dumpall")) { + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const next = line[i + 1]; + if (quote !== "") { + if (char === quote) { + if (next === quote) i++; + else quote = ""; + } + } else if (char === "'" || char === '"') quote = char; + else if (char === "-" && next === "-") break; + } + } + return transformDumpLine(line, script, env, inQuotedSql); + }; const onChunk = (bytes: Uint8Array) => Effect.suspend(() => { carry += decoder.decode(bytes, { stream: true }); @@ -312,7 +339,7 @@ const stackDumpStdout = ( return Effect.forEach( lines, (line) => { - const transformed = transformDumpLine(line, script, env); + const transformed = transformLine(line); if ( transformed === undefined || (script.includes("pg_dumpall") && transformed === previous) @@ -327,7 +354,7 @@ const stackDumpStdout = ( const flush = () => Effect.suspend(() => { carry += decoder.decode(); - const final = transformDumpLine(carry, script, env); + const final = transformLine(carry); return final === undefined || final.length === 0 || (script.includes("pg_dumpall") && final === previous) diff --git a/apps/cli/src/command-internal/pg-dump.scripts.ts b/apps/cli/src/command-internal/pg-dump.scripts.ts index cbce14c3df..2dbb187405 100644 --- a/apps/cli/src/command-internal/pg-dump.scripts.ts +++ b/apps/cli/src/command-internal/pg-dump.scripts.ts @@ -8,4 +8,4 @@ export const dumpDataScript = '#!/usr/bin/env bash\nset -euo pipefail\n\nexport PGHOST="$PGHOST"\nexport PGPORT="$PGPORT"\nexport PGUSER="$PGUSER"\nexport PGPASSWORD="$PGPASSWORD"\nexport PGDATABASE="$PGDATABASE"\n\n# Disable triggers so that data dump can be restored exactly as it is\necho "SET session_replication_role = replica;\n"\n\n# Explanation of pg_dump flags:\n#\n# --exclude-schema omit data from internal schemas as they are maintained by platform\n# --exclude-table omit data from migration history tables as they are managed by platform\n# --column-inserts only column insert syntax is supported, ie. no copy from stdin\n# --schema \'*\' include all other schemas by default\n#\n# Explanation of sed substitutions:\n#\n# - do not emit psql meta commands\n#\n# Never delete SQL comments because multiline records may begin with them.\npg_dump \\\n --data-only \\\n --quote-all-identifier \\\n --role "postgres" \\\n --exclude-schema "${EXCLUDED_SCHEMAS:-}" \\\n --exclude-table "auth.schema_migrations" \\\n --exclude-table "storage.migrations" \\\n --exclude-table "supabase_functions.migrations" \\\n --schema "$INCLUDED_SCHEMAS" \\\n ${EXTRA_FLAGS:-} \\\n| sed -E \'s/^\\\\(un)?restrict .*$/-- &/\'\n\n# Reset session config generated by pg_dump\necho "RESET ALL;"\n'; export const dumpRoleScript = - '#!/usr/bin/env bash\nset -euo pipefail\n\nexport PGHOST="$PGHOST"\nexport PGPORT="$PGPORT"\nexport PGUSER="$PGUSER"\nexport PGPASSWORD="$PGPASSWORD"\nexport PGDATABASE="$PGDATABASE"\n\n# Explanation of pg_dumpall flags:\n#\n# --roles-only only include create, alter, and grant role statements\n#\n# Explanation of sed substitutions:\n#\n# - do not emit psql meta commands\n# - do not create or alter reserved roles as they are blocked by supautils\n# - explicitly allow altering safe attributes, ie. statement_timeout, pgrst.*\n# - discard role attributes that require superuser, ie. nosuperuser, noreplication\n# - do not alter membership grants by supabase_admin role\npg_dumpall \\\n --roles-only \\\n --role "postgres" \\\n --quote-all-identifier \\\n --no-role-passwords \\\n --no-comments \\\n| sed -E \'s/^\\\\(un)?restrict .*$/-- &/\' \\\n| sed -E "s/^CREATE ROLE \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "s/^ALTER ROLE \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "s/ (NOSUPERUSER|NOREPLICATION)//g" \\\n| sed -E "s/^-- (.* SET \\"($ALLOWED_CONFIGS)\\" .*)/\\1/" \\\n| sed -E "s/GRANT \\".*\\" TO \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "${EXTRA_SED:-}" \\\n| uniq\n\n# Reset session config generated by pg_dump\necho "RESET ALL;"\n'; + '#!/usr/bin/env bash\nset -euo pipefail\n\nexport PGHOST="$PGHOST"\nexport PGPORT="$PGPORT"\nexport PGUSER="$PGUSER"\nexport PGPASSWORD="$PGPASSWORD"\nexport PGDATABASE="$PGDATABASE"\n\n# Explanation of pg_dumpall flags:\n#\n# --roles-only only include create, alter, and grant role statements\n#\n# Explanation of sed substitutions:\n#\n# - do not emit psql meta commands\n# - do not create or alter reserved roles as they are blocked by supautils\n# - explicitly allow altering safe attributes, ie. statement_timeout, pgrst.*\n# - discard role attributes that require superuser, ie. nosuperuser, noreplication\n# - do not alter membership grants by supabase_admin role\n# - do not grant parameter privileges to reserved roles outside quoted SQL\npg_dumpall \\\n --roles-only \\\n --role "postgres" \\\n --quote-all-identifier \\\n --no-role-passwords \\\n --no-comments \\\n| LC_ALL=C awk -v reserved_roles="$RESERVED_ROLES" \'\n# Parenthesized field references survive the CLI dry-run expander.\nBEGIN { single_quote = sprintf("%c", 39) }\n{\n line = $(0)\n if (quote == "" && line ~ ("^GRANT [A-Z ,]+ ON PARAMETER \\".*\\" TO \\"(" reserved_roles ")\\"([ ;]|$)"))\n $(0) = "-- " line\n print\n count = length(line)\n end = 0\n for (i = 1; i <= count; i++) {\n if (i > end) {\n chunk = substr(line, i, 1025)\n start = i\n end = i + 1023\n }\n char = substr(chunk, i - start + 1, 1)\n next_char = substr(chunk, i - start + 2, 1)\n if (quote != "") {\n if (char == quote) {\n if (next_char == quote) i++\n else quote = ""\n }\n } else if (char == single_quote || char == "\\"") quote = char\n else if (char == "-" && next_char == "-") break\n }\n}\' \\\n| sed -E \'s/^\\\\(un)?restrict .*$/-- &/\' \\\n| sed -E "s/^CREATE ROLE \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "s/^ALTER ROLE \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "s/ (NOSUPERUSER|NOREPLICATION)//g" \\\n| sed -E "s/^-- (.* SET \\"($ALLOWED_CONFIGS)\\" .*)/\\1/" \\\n| sed -E "s/GRANT \\".*\\" TO \\"($RESERVED_ROLES)\\"/-- &/" \\\n| sed -E "${EXTRA_SED:-}" \\\n| uniq\n\n# Reset session config generated by pg_dump\necho "RESET ALL;"\n'; diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index 63603fe700..ef3de9c9f4 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -2,7 +2,7 @@ * On-disk locations for pg-delta-adjacent cache/snapshot artefacts. * * Two roots: - * - `pgdeltaScratchDir`: `supabase/.temp/pgdelta` in legacy state (Go-shared, workspace-mounted), + * - `pgdeltaScratchDir`: `supabase/.temp/pgdelta` in legacy state (workspace-mounted), * `$SUPABASE_HOME/tmp/pgdelta/` in stack state — catalog snapshots and debug bundles. * - {@link shadowBaselineCacheDir}: global under `SUPABASE_HOME` — the shadow * baseline PGDATA tars, shared across worktrees with the same settings. @@ -10,21 +10,15 @@ import { homedir } from "node:os"; -import type { Path } from "effect"; +import { Effect, type Path } from "effect"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; +import { readSupabaseHome } from "../shared/config/supabase-home.ts"; /** * Global shadow-baseline cache directory: * `${SUPABASE_HOME}/cache/shadow-baseline` (default `~/.supabase/cache/shadow-baseline`). - * - * Pure: callers may pass `env`/`homeDir` for tests; production uses `process.env` and - * `os.homedir()`. */ -export function shadowBaselineCacheDir( - path: Path.Path, - env: Readonly> = process.env, - homeDir: string = homedir(), -): string { - return path.join(resolveSupabaseHome(path, env, homeDir), "cache", "shadow-baseline"); -} +export const shadowBaselineCacheDir = (path: Path.Path, homeDir: string = homedir()) => + Effect.map(readSupabaseHome(path, homeDir), (home) => + path.join(home, "cache", "shadow-baseline"), + ); diff --git a/apps/cli/src/command-internal/pgdelta.ts b/apps/cli/src/command-internal/pgdelta.ts index b00322df8f..2e8e35090a 100644 --- a/apps/cli/src/command-internal/pgdelta.ts +++ b/apps/cli/src/command-internal/pgdelta.ts @@ -1,6 +1,6 @@ -import { Config, Effect, Option, type Path } from "effect"; +import { Effect } from "effect"; -import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; +import { envValue } from "../shared/config/env-option.ts"; /** * Ambient inputs shared by the pg-delta and migra diff workflows: the project id @@ -20,32 +20,6 @@ export interface PgDeltaContext { readonly projectEnv: Readonly>; } -/** - * Resolves {@link PgDeltaContext.projectId} — not `CommandSettings.projectId` alone, which is - * env-only and resolves to `""` for a project relying on config.toml's `project_id` or the - * workdir-basename default, mounting the wrong `supabase_edge_runtime_` Deno-cache volume. - * Shared by every pg-delta context builder (`db diff`, `db pull`, declarative generate/sync). - * - * When a matched `[remotes.]` block already resolved its own `project_id` into - * `toml.projectId`, the raw `cliProjectId` argument is suppressed entirely — otherwise an - * unrelated ambient `SUPABASE_PROJECT_ID` could win back over the matched remote's id. - */ -export function resolvePgDeltaProjectId( - path: Path.Path, - cliProjectId: Option.Option, - toml: { readonly projectId: Option.Option; readonly appliedRemote: string | undefined }, - workdir: string, -): string { - return sanitizeProjectId( - resolveLocalProjectId( - path, - toml.appliedRemote !== undefined ? undefined : Option.getOrUndefined(cliProjectId), - Option.getOrUndefined(toml.projectId), - workdir, - ), - ); -} - export function isPostgresURL(ref: string): boolean { return ref.startsWith("postgres://") || ref.startsWith("postgresql://"); } @@ -55,11 +29,10 @@ export function edgeRuntimeId(projectId: string): string { } /** Recognizes `PGDELTA_DEBUG=1`/`true`/`yes` (case-insensitive). */ -export const isPgDeltaDebugEnabled = Config.String("PGDELTA_DEBUG").pipe( - Config.withDefault(""), - Config.map((raw) => { - const value = raw.trim().toLowerCase(); +export const isPgDeltaDebugEnabled: Effect.Effect = Effect.map( + envValue("PGDELTA_DEBUG"), + (raw) => { + const value = (raw ?? "").trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; - }), - Effect.orElseSucceed(() => false), + }, ); diff --git a/apps/cli/src/command-internal/pgdelta.unit.test.ts b/apps/cli/src/command-internal/pgdelta.unit.test.ts index 95fa4e2012..f5aa331800 100644 --- a/apps/cli/src/command-internal/pgdelta.unit.test.ts +++ b/apps/cli/src/command-internal/pgdelta.unit.test.ts @@ -23,7 +23,8 @@ describe("isPgDeltaDebugEnabled", () => { it.effect("is true for 1/true/yes (case-insensitive, trimmed)", () => Effect.gen(function* () { for (const value of ["1", "true", "YES", " True "]) { - expect(yield* withConfigEnv({ PGDELTA_DEBUG: value }, isPgDeltaDebugEnabled)).toBe(true); + const debug = yield* withConfigEnv({ PGDELTA_DEBUG: value }, isPgDeltaDebugEnabled); + expect(debug).toBe(true); } }), ); diff --git a/apps/cli/src/command-internal/pooler-fallback.ts b/apps/cli/src/command-internal/pooler-fallback.ts index 7ecdc66e31..ecc9a0b4d4 100644 --- a/apps/cli/src/command-internal/pooler-fallback.ts +++ b/apps/cli/src/command-internal/pooler-fallback.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Predicate } from "effect"; import { Output } from "../shared/output/output.service.ts"; import { yellow } from "./colors.ts"; @@ -8,6 +8,21 @@ export function isDirectDbHost(host: string, projectHost: string): boolean { return host.startsWith("db.") && host.endsWith(`.${projectHost}`); } +/** + * Treats a failed pooler resolution as "no fallback" so the caller keeps its original error, except + * an unapproved network-ban removal, whose "re-run with --yes" guidance would otherwise be lost. + */ +export const orNoPoolerFallback = ( + resolve: Effect.Effect, E, R>, +) => + resolve.pipe( + Effect.catch((error) => + Predicate.isTagged(error, "LinkedDbNetworkBannedError") + ? Effect.fail(error) + : Effect.succeed(Option.none()), + ), + ); + export interface PoolerFallbackOptions { readonly run: Effect.Effect; readonly retry: (pooler: PgConnInput) => Effect.Effect; @@ -32,10 +47,12 @@ export const emitPoolerFallbackWarning = (host: string): Effect.Effect( options: PoolerFallbackOptions, -): Effect.Effect { - const resolveFallback = options.resolveFallback.pipe( - Effect.orElseSucceed(() => Option.none()), - ); +): Effect.Effect< + A, + E | (EF & { readonly _tag: "LinkedDbNetworkBannedError" }), + R | R2 | RF | Output +> { + const resolveFallback = orNoPoolerFallback(options.resolveFallback); const retryOrReturn = (result: A) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts index b32a0f8444..08e43655ca 100644 --- a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts +++ b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { Output } from "../shared/output/output.service.ts"; +import { DbConfigPoolerLoginError, LinkedDbNetworkBannedError } from "./db-config.errors.ts"; import type { PgConnInput } from "./db-connection.service.ts"; import { runWithPoolerFallback } from "./pooler-fallback.ts"; @@ -175,4 +176,46 @@ describe("runWithPoolerFallback", () => { expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); }).pipe(Effect.provide(out.layer)); }); + + it.live( + "surfaces an unapproved network-ban removal instead of keeping the original result", + () => { + const out = captureOutput(); + const banned = new LinkedDbNetworkBannedError({ + message: "Network bans (203.0.113.7) may be blocking the connection.", + suggestion: "Re-run with --yes to remove them.", + }); + + return Effect.gen(function* () { + const exit = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(banned), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }).pipe(Effect.exit); + + expect(exit).toEqual(Exit.fail(banned)); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(out.layer)); + }, + ); + + it.live("keeps the original result when the pooler cannot be resolved for another reason", () => { + const out = captureOutput(); + + return Effect.gen(function* () { + const result = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(new DbConfigPoolerLoginError({ message: "tenant mismatch" })), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }); + + expect(result).toEqual({ exitCode: 1, stderr: "network is unreachable" }); + }).pipe(Effect.provide(out.layer)); + }); }); diff --git a/apps/cli/src/command-internal/postgres-url.ts b/apps/cli/src/command-internal/postgres-url.ts index 02aa1b22ef..bf72a4c761 100644 --- a/apps/cli/src/command-internal/postgres-url.ts +++ b/apps/cli/src/command-internal/postgres-url.ts @@ -13,7 +13,7 @@ function isIPv6Host(host: string): boolean { * and turns space into `+` (`application/x-www-form-urlencoded` query-escaping, not * `encodeURIComponent`, which differs on space and `!*'()`). */ -function goQueryEscape(value: string): string { +function queryEscape(value: string): string { let out = ""; for (const ch of value) { if (/[A-Za-z0-9\-_.~]/.test(ch)) { @@ -74,7 +74,7 @@ export function toPostgresURL(conn: PostgresUrlInput): string { // tenant on pooler fallback. const optionsParam = conn.options !== undefined && conn.options.length > 0 - ? `&options=${goQueryEscape(conn.options)}` + ? `&options=${queryEscape(conn.options)}` : ""; // Every other runtime param (search_path, statement_timeout, …), sorted for stable // serialization. @@ -83,7 +83,7 @@ export function toPostgresURL(conn: PostgresUrlInput): string { ? "" : Object.keys(conn.runtimeParams) .sort() - .map((key) => `&${goQueryEscape(key)}=${goQueryEscape(conn.runtimeParams![key]!)}`) + .map((key) => `&${queryEscape(key)}=${queryEscape(conn.runtimeParams![key]!)}`) .join(""); return `postgresql://${userinfo}@${host}:${conn.port}/${encodeURIComponent( conn.database, diff --git a/apps/cli/src/command-internal/postgres-url.unit.test.ts b/apps/cli/src/command-internal/postgres-url.unit.test.ts index 2088bdad7c..a8a948bbca 100644 --- a/apps/cli/src/command-internal/postgres-url.unit.test.ts +++ b/apps/cli/src/command-internal/postgres-url.unit.test.ts @@ -47,14 +47,14 @@ describe("toPostgresURL", () => { }); it("appends the pooler `options` runtime param after connect_timeout", () => { - // Go's ToPostgresURL appends RuntimeParams; the Supavisor tenant routing + // The Supavisor tenant routing // `options=reference=` must reach pg-delta (`=` escaped to %3D). expect(toPostgresURL({ ...base, options: "reference=abcdefghijklmnop" })).toBe( "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10&options=reference%3Dabcdefghijklmnop", ); }); - it("matches Go's url.QueryEscape for options (space → +)", () => { + it("query-escapes options (space → +)", () => { expect(toPostgresURL({ ...base, options: "-c search_path=public" })).toContain( "&options=-c+search_path%3Dpublic", ); @@ -67,7 +67,7 @@ describe("toPostgresURL", () => { ); }); - it("appends every runtimeParams entry (sorted) after options, like Go ToPostgresURL", () => { + it("appends every runtimeParams entry (sorted) after options", () => { expect( toPostgresURL({ ...base, @@ -79,7 +79,7 @@ describe("toPostgresURL", () => { ); }); - it("escapes runtimeParams values like Go's url.QueryEscape", () => { + it("query-escapes runtimeParams values", () => { expect(toPostgresURL({ ...base, runtimeParams: { search_path: "a b,c" } })).toContain( "&search_path=a+b%2Cc", ); diff --git a/apps/cli/src/command-internal/profile-load.ts b/apps/cli/src/command-internal/profile-load.ts index 221c79493b..940bf91c9b 100644 --- a/apps/cli/src/command-internal/profile-load.ts +++ b/apps/cli/src/command-internal/profile-load.ts @@ -64,17 +64,17 @@ export function loadProfile( }; } - // An empty token falls back to the established "no config file" search-path error. + // An empty token has no config file to read. if (token === "") { - return yield* failRead(`Config File "config" Not Found in "[]"`); + return yield* failRead("no profile config file specified"); } - const ext = goFilepathExt(token); - if (!VIPER_SUPPORTED_EXTS.has(ext)) { + const ext = fileExtension(token); + if (!PROFILE_FILE_EXTS.has(ext)) { const quoted = yield* quoteJsonString(ext).pipe( Effect.mapError((error) => readError(error.message)), ); - return yield* failRead(`Unsupported Config Type ${quoted}`); + return yield* failRead(`unsupported config file type ${quoted}`); } const content = yield* fs @@ -95,17 +95,13 @@ export function loadProfile( let parsed = yield* Effect.try({ try: (): unknown => parseYaml(content), - catch: (cause) => readError(`While parsing config: ${parseDetail(cause)}`), + catch: (cause) => readError(`invalid config file: ${parseDetail(cause)}`), }); if (parsed === null || parsed === undefined) { parsed = {}; } if (typeof parsed !== "object" || Array.isArray(parsed)) { - // A non-mapping YAML document (e.g. a scalar or list) is rejected; detail text is - // best-effort. - return yield* failRead( - `While parsing config: yaml: unmarshal errors:\n cannot unmarshal into map[string]interface {}`, - ); + return yield* failRead("invalid config file: expected a YAML mapping at the top level"); } // Configuration keys are lowercased before decoding, so `API_URL:`/`Name:` behave like // their lowercase spellings, and unknown-key errors report the lowercased form. A @@ -115,15 +111,15 @@ export function loadProfile( config[key.toLowerCase()] = value; } - // Unknown keys abort decoding, reported sorted in a padded multi-line block. + // Unknown keys abort decoding, reported sorted. const invalidKeys = Object.keys(config) .filter((key) => !PROFILE_STRUCT_KEYS.has(key)) .sort(); if (invalidKeys.length > 0) { - return yield* failDecode(`'utils.Profile' has invalid keys: ${invalidKeys.join(", ")}`); + return yield* failDecode(`unknown keys: ${invalidKeys.join(", ")}`); } - // Weak scalar decoding + per-field tag validation, in struct field order. + // Weak scalar decoding + per-field format validation, in field order. const decodeErrors: string[] = []; const validationErrors: string[] = []; const values = new Map(); @@ -131,27 +127,28 @@ export function loadProfile( const raw = config[field.key]; const weak = weakString(raw); if (weak === undefined) { - decodeErrors.push( - `'${field.goName}' expected type 'string', got unconvertible type '${goTypeName(raw)}', value: '${goValueString(raw)}'`, + const shown = yield* encodeJsonValue(raw).pipe( + Effect.mapError((error) => readError(error.message)), ); + decodeErrors.push(`${field.key}: expected a string, got ${valueTypeName(raw)}: ${shown}`); continue; } values.set(field.key, weak); if (weak === "") { if (field.required) { - validationErrors.push(validatorLine(field.goName, "required")); + validationErrors.push(`${field.key} is required`); } continue; } if (field.format !== undefined && !FORMAT_TAG_CHECKS[field.format](weak)) { - validationErrors.push(validatorLine(field.goName, field.format)); + validationErrors.push(`${field.key} ${FORMAT_TAG_DESCRIPTIONS[field.format]}`); } } if (decodeErrors.length > 0) { return yield* failDecode(decodeErrors.join("\n")); } if (validationErrors.length > 0) { - return yield* fail(padGoErrorBlock(`invalid profile: ${validationErrors.join("\n")}`)); + return yield* fail(`invalid profile:\n${validationErrors.join("\n")}`); } // All required fields passed validation above; pooler_host stays "" when absent. @@ -174,16 +171,14 @@ const failRead = (detail: string) => Effect.fail(readError(detail)); const quoteJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); +/** JSON rendering of a non-string profile value for decode-error messages. */ +const encodeJsonValue = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + /** Aggregate decode-error template: multiple failing fields render as one block. */ -const failDecode = (detail: string) => - fail( - padGoErrorBlock( - `failed to parse profile: decoding failed due to the following error(s):\n\n${detail}`, - ), - ); +const failDecode = (detail: string) => fail(`failed to parse profile:\n${detail}`); /** Recognized config file extensions, checked case-sensitively. */ -const VIPER_SUPPORTED_EXTS: ReadonlySet = new Set([ +const PROFILE_FILE_EXTS: ReadonlySet = new Set([ "json", "toml", "yaml", @@ -202,7 +197,7 @@ const VIPER_SUPPORTED_EXTS: ReadonlySet = new Set([ * Returns everything after the last `.` in the final path segment, including for dot-files * (`.yml` → `yml`), where Node's `path.extname` returns `""`. */ -function goFilepathExt(token: string): string { +function fileExtension(token: string): string { for (let i = token.length - 1; i >= 0 && token[i] !== "/"; i--) { if (token[i] === ".") { return token.slice(i + 1); @@ -228,7 +223,6 @@ type FormatTag = "http_url" | "hostname_rfc1123" | "uuid4"; interface ProfileStringField { readonly key: string; - readonly goName: string; readonly required: boolean; readonly format?: FormatTag; } @@ -238,19 +232,29 @@ interface ProfileStringField { * `regions` (a slice) is exempt from weak string decoding and never validated. */ const PROFILE_STRING_FIELDS: ReadonlyArray = [ - { key: "name", goName: "Name", required: true }, - { key: "api_url", goName: "APIURL", required: true, format: "http_url" }, - { key: "dashboard_url", goName: "DashboardURL", required: true, format: "http_url" }, - { key: "docs_url", goName: "DocsURL", required: false, format: "http_url" }, - { key: "project_host", goName: "ProjectHost", required: true, format: "hostname_rfc1123" }, - { key: "pooler_host", goName: "PoolerHost", required: false, format: "hostname_rfc1123" }, - { key: "client_id", goName: "AuthClientID", required: false, format: "uuid4" }, - { key: "studio_image", goName: "StudioImage", required: false }, + { key: "name", required: true }, + { key: "api_url", required: true, format: "http_url" }, + { key: "dashboard_url", required: true, format: "http_url" }, + { key: "docs_url", required: false, format: "http_url" }, + { + key: "project_host", + required: true, + format: "hostname_rfc1123", + }, + { + key: "pooler_host", + required: false, + format: "hostname_rfc1123", + }, + { key: "client_id", required: false, format: "uuid4" }, + { key: "studio_image", required: false }, ]; -function validatorLine(goName: string, tag: string): string { - return `Key: 'Profile.${goName}' Error:Field validation for '${goName}' failed on the '${tag}' tag`; -} +const FORMAT_TAG_DESCRIPTIONS: Record = { + http_url: "must be an http(s) URL", + hostname_rfc1123: "must be a valid hostname", + uuid4: "must be a lowercase UUID v4", +}; /** RFC 1123 hostname pattern. */ const HOSTNAME_RFC1123 = /^([a-zA-Z0-9][a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,62})*?$/; @@ -274,7 +278,7 @@ const FORMAT_TAG_CHECKS: Record boolean> = { /** * Weak string coercion: strings pass through, booleans become `"1"`/`"0"`, numbers are - * stringified, and `null`/`undefined` decode to `""`. Arrays/objects are unconvertible + * stringified, and `null`/`undefined` decode to `""`. Arrays/objects are unsupported * (`undefined`, a decode error). */ function weakString(value: unknown): string | undefined { @@ -285,34 +289,12 @@ function weakString(value: unknown): string | undefined { return undefined; } -function goTypeName(value: unknown): string { - if (Array.isArray(value)) return "[]interface {}"; - if (typeof value === "object" && value !== null) return "map[string]interface {}"; +function valueTypeName(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (typeof value === "object" && value !== null) return "object"; return typeof value; } -/** Best-effort rendering of an invalid field's value for the error message. */ -function goValueString(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(goValueString).join(" ")}]`; - if (typeof value === "object" && value !== null) { - const entries = Object.entries(value as Record) - .map(([key, entry]) => `${key}:${goValueString(entry)}`) - .join(" "); - return `map[${entries}]`; - } - return String(value); -} - -/** - * Pads every line (including blank ones) with trailing spaces to the longest line's width, to - * match the CLI's established multi-line error rendering exactly. - */ -export function padGoErrorBlock(message: string): string { - const lines = message.split("\n"); - const width = Math.max(...lines.map((line) => line.length)); - return lines.map((line) => line.padEnd(width)).join("\n"); -} - function parseDetail(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/cli/src/command-internal/profile-load.unit.test.ts b/apps/cli/src/command-internal/profile-load.unit.test.ts index ead3c719b4..8543efc1f3 100644 --- a/apps/cli/src/command-internal/profile-load.unit.test.ts +++ b/apps/cli/src/command-internal/profile-load.unit.test.ts @@ -2,7 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; -import { loadProfile, padGoErrorBlock, type ProfileLoadError } from "./profile-load.ts"; +import { loadProfile, type ProfileLoadError } from "./profile-load.ts"; const load = (token: string) => Effect.gen(function* () { @@ -33,7 +33,7 @@ const writeProfile = (name: string, content: string) => }).pipe(Effect.provide(BunServices.layer)); describe("loadProfile", () => { - it.effect("resolves built-in profile names case-insensitively (Go strings.EqualFold)", () => + it.effect("resolves built-in profile names case-insensitively", () => Effect.gen(function* () { expect(yield* load("SUPABASE-LOCAL")).toBe("http://localhost:8080"); expect(yield* load("supabase")).toBe("https://api.supabase.com"); @@ -42,11 +42,9 @@ describe("loadProfile", () => { }), ); - it.effect("fails on an empty token with viper's search-mode error (Go `--profile=`)", () => + it.effect("fails an empty --profile= token with the search-mode error", () => Effect.gen(function* () { - expect(yield* loadError("")).toBe( - `failed to read profile: Config File "config" Not Found in "[]"`, - ); + expect(yield* loadError("")).toBe(`failed to read profile: no profile config file specified`); }), ); @@ -54,15 +52,15 @@ describe("loadProfile", () => { Effect.gen(function* () { // --profile --metadata-url … expect(yield* loadError("--metadata-url")).toBe( - `failed to read profile: Unsupported Config Type ""`, + `failed to read profile: unsupported config file type ""`, ); expect(yield* loadError("profile.txt")).toBe( - `failed to read profile: Unsupported Config Type "txt"`, + `failed to read profile: unsupported config file type "txt"`, ); }), ); - it.effect("uses Go filepath.Ext semantics for dot-files (`.yml` IS extension `yml`)", () => + it.effect("treats dot-files as having an extension (`.yml` IS extension `yml`)", () => Effect.gen(function* () { const missing = yield* tempPath(".yml"); expect(yield* loadError(missing)).toBe( @@ -71,7 +69,7 @@ describe("loadProfile", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("fails on a missing file with Go's os.Open error", () => + it.effect("fails on a missing file with the open error", () => Effect.gen(function* () { expect(yield* loadError("missing.yml")).toBe( `failed to read profile: open missing.yml: no such file or directory`, @@ -79,7 +77,7 @@ describe("loadProfile", () => { }), ); - it.effect("fails on a directory with Go's read error", () => + it.effect("fails on a directory with the read error", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const dir = yield* tempPath("dir.yml"); @@ -103,7 +101,7 @@ describe("loadProfile", () => { }), ); - it.effect("accepts mixed-case keys like viper's insensitive decode (probed on go1.26)", () => + it.effect("accepts mixed-case keys case-insensitively", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const file = yield* writeProfile( @@ -146,7 +144,7 @@ describe("loadProfile", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("reports unknown keys LOWERCASED, like viper's pre-decode normalization", () => + it.effect("reports unknown keys LOWERCASED", () => Effect.gen(function* () { const file = yield* writeProfile( "bogus-upper.yml", @@ -158,30 +156,28 @@ describe("loadProfile", () => { "BOGUS_KEY: x", ].join("\n"), ); - expect(yield* loadError(file)).toContain("'utils.Profile' has invalid keys: bogus_key"); + expect(yield* loadError(file)).toContain("unknown keys: bogus_key"); }), ); - it.effect( - "returns Go's CurrentProfile.Name — the canonical built-in or the file's name field", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); - const file = yield* writeProfile( - "named.yml", - [ - "name: harness", - "api_url: http://127.0.0.1:44444", - "dashboard_url: http://127.0.0.1:44444/dashboard", - "project_host: supabase.co", - ].join("\n"), - ); - expect((yield* loadProfile(file, fs)).name).toBe("harness"); - }).pipe(Effect.provide(BunServices.layer)), + it.effect("returns the profile name — the canonical built-in or the file's name field", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); + const file = yield* writeProfile( + "named.yml", + [ + "name: harness", + "api_url: http://127.0.0.1:44444", + "dashboard_url: http://127.0.0.1:44444/dashboard", + "project_host: supabase.co", + ].join("\n"), + ); + expect((yield* loadProfile(file, fs)).name).toBe("harness"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects unknown keys with mapstructure's padded UnmarshalExact block", () => + it.effect("rejects unknown keys with a sorted unknown-keys line", () => Effect.gen(function* () { const file = yield* writeProfile( "extra-keys.yml", @@ -194,27 +190,24 @@ describe("loadProfile", () => { "db_url: postgres://localhost:5432/db", ].join("\n"), ); - const line1 = "failed to parse profile: decoding failed due to the following error(s):"; - const line3 = "'utils.Profile' has invalid keys: db_url, gotrue_url"; expect(yield* loadError(file)).toBe( - [line1, "".padEnd(line1.length), line3.padEnd(line1.length)].join("\n"), + "failed to parse profile:\nunknown keys: db_url, gotrue_url", ); }), ); - it.effect( - "reports missing required fields with the validator's padded lines, in struct order", - () => - Effect.gen(function* () { - const file = yield* writeProfile("incomplete.yml", "name: incomplete\n"); - const lines = [ - "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'required' tag", - "Key: 'Profile.DashboardURL' Error:Field validation for 'DashboardURL' failed on the 'required' tag", - "Key: 'Profile.ProjectHost' Error:Field validation for 'ProjectHost' failed on the 'required' tag", - ]; - const width = Math.max(...lines.map((line) => line.length)); - expect(yield* loadError(file)).toBe(lines.map((line) => line.padEnd(width)).join("\n")); - }), + it.effect("reports missing required fields one per line, in field order", () => + Effect.gen(function* () { + const file = yield* writeProfile("incomplete.yml", "name: incomplete\n"); + expect(yield* loadError(file)).toBe( + [ + "invalid profile:", + "api_url is required", + "dashboard_url is required", + "project_host is required", + ].join("\n"), + ); + }), ); it.effect("reports a missing name (only) — required covers empty strings", () => @@ -227,29 +220,23 @@ describe("loadProfile", () => { "project_host: supabase.co", ].join("\n"), ); - expect(yield* loadError(file)).toBe( - "invalid profile: Key: 'Profile.Name' Error:Field validation for 'Name' failed on the 'required' tag", - ); + expect(yield* loadError(file)).toBe("invalid profile:\nname is required"); }), ); - it.effect( - "weakly stringifies scalars like viper, so `api_url: 123` fails http_url, not decoding", - () => - Effect.gen(function* () { - const file = yield* writeProfile( - "typebad.yml", - [ - "name: t", - "api_url: 123", - "dashboard_url: http://127.0.0.1:44444/dashboard", - "project_host: supabase.co", - ].join("\n"), - ); - expect(yield* loadError(file)).toBe( - "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'http_url' tag", - ); - }), + it.effect("weakly stringifies scalars, so `api_url: 123` fails http_url, not decoding", () => + Effect.gen(function* () { + const file = yield* writeProfile( + "typebad.yml", + [ + "name: t", + "api_url: 123", + "dashboard_url: http://127.0.0.1:44444/dashboard", + "project_host: supabase.co", + ].join("\n"), + ); + expect(yield* loadError(file)).toBe("invalid profile:\napi_url must be an http(s) URL"); + }), ); it.effect("validates the hostname_rfc1123 and http_url format tags", () => @@ -263,24 +250,25 @@ describe("loadProfile", () => { "project_host: 'bad host!'", ].join("\n"), ); - const lines = [ - "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'http_url' tag", - "Key: 'Profile.ProjectHost' Error:Field validation for 'ProjectHost' failed on the 'hostname_rfc1123' tag", - ]; - const width = Math.max(...lines.map((line) => line.length)); - expect(yield* loadError(file)).toBe(lines.map((line) => line.padEnd(width)).join("\n")); + expect(yield* loadError(file)).toBe( + [ + "invalid profile:", + "api_url must be an http(s) URL", + "project_host must be a valid hostname", + ].join("\n"), + ); }), ); - it.effect("fails a malformed YAML file closed with viper's parse prefix", () => + it.effect("fails a malformed YAML file closed with the parse-error prefix", () => Effect.gen(function* () { const file = yield* writeProfile("malformed.yml", "name: [broken\n api_url"); const message = yield* loadError(file); - expect(message).toMatch(/^failed to read profile: While parsing config: /); + expect(message).toMatch(/^failed to read profile: invalid config file: /); }), ); - it.effect("fails closed on unconvertible values (array on a string field)", () => + it.effect("fails closed on non-scalar values (array on a string field)", () => Effect.gen(function* () { const file = yield* writeProfile( "arrayval.yml", @@ -292,22 +280,9 @@ describe("loadProfile", () => { ].join("\n"), ); const message = yield* loadError(file); - expect(message).toContain( - "failed to parse profile: decoding failed due to the following error(s):", - ); - expect(message).toContain( - "'APIURL' expected type 'string', got unconvertible type '[]interface {}'", + expect(message).toBe( + 'failed to parse profile:\napi_url: expected a string, got array: ["http://a","http://b"]', ); }), ); }); - -describe("padGoErrorBlock", () => { - it("pads every line — including blank ones — to the longest line's width", () => { - expect(padGoErrorBlock("abc\n\nlonger line")).toBe("abc \n \nlonger line"); - }); - - it("leaves single-line messages untouched", () => { - expect(padGoErrorBlock("only line")).toBe("only line"); - }); -}); diff --git a/apps/cli/src/command-internal/profile.unit.test.ts b/apps/cli/src/command-internal/profile.unit.test.ts index a83ae15b7f..7bf618d3be 100644 --- a/apps/cli/src/command-internal/profile.unit.test.ts +++ b/apps/cli/src/command-internal/profile.unit.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { billingUrl, dashboardUrl, poolerHost, projectHost } from "./profile.ts"; describe("projectHost", () => { - it("maps built-in profile names to the Go project_host", () => { + it("maps built-in profile names to the project_host", () => { expect(projectHost("supabase")).toBe("supabase.co"); expect(projectHost("supabase-staging")).toBe("supabase.red"); expect(projectHost("supabase-local")).toBe("supabase.red"); @@ -16,7 +16,7 @@ describe("projectHost", () => { }); describe("poolerHost", () => { - it("maps built-in profile names to the Go pooler_host", () => { + it("maps built-in profile names to the pooler_host", () => { expect(poolerHost("supabase")).toBe("supabase.com"); expect(poolerHost("supabase-staging")).toBe("supabase.green"); expect(poolerHost("snap")).toBe("snapcloud.co"); @@ -32,7 +32,7 @@ describe("poolerHost", () => { }); describe("dashboardUrl", () => { - it("maps built-in profile names to the Go dashboard_url", () => { + it("maps built-in profile names to the dashboard_url", () => { expect(dashboardUrl("supabase")).toBe("https://supabase.com/dashboard"); expect(dashboardUrl("supabase-staging")).toBe("https://supabase.green/dashboard"); expect(dashboardUrl("supabase-local")).toBe("http://localhost:8082"); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 4735453730..98322ce4fe 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -4,9 +4,16 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { OutputFlag } from "./global-flags.ts"; +import { requireHostedMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { Output } from "../shared/output/output.service.ts"; -import { encodeEnv, encodeGoJson } from "./go-output.encoders.ts"; -import { encodeGoToml, encodeGoYaml, goString, goStruct } from "./go-struct-output.encoders.ts"; +import { encodeEnv, encodeSortedJson } from "./output.encoders.ts"; +import { + encodeStructToml, + encodeStructYaml, + shapeString, + shapeStruct, +} from "./struct-output.encoders.ts"; import { sanitizeErrorBody } from "./http-errors.ts"; import { ProjectsCreateNetworkError, @@ -27,15 +34,15 @@ import { type CreateInput = typeof V1CreateAProjectInput.Type; /** Struct spec driving `-o yaml|toml` key casing for `projects create`'s raw response. */ -const GO_PROJECT_RESPONSE = goStruct([ - ["created_at", goString], - ["id", goString], - ["name", goString], - ["organization_id", goString], - ["organization_slug", goString], - ["ref", goString], - ["region", goString], - ["status", goString], +const PROJECT_RESPONSE_SHAPE = shapeStruct([ + ["created_at", shapeString], + ["id", shapeString], + ["name", shapeString], + ["organization_id", shapeString], + ["organization_slug", shapeString], + ["ref", shapeString], + ["region", shapeString], + ["status", shapeString], ]); export interface ProjectCreateInput { @@ -53,6 +60,8 @@ export interface ProjectCreateInput { * `bootstrap` suppresses it since it emits its own top-level result instead. */ readonly emitStructuredResult: boolean; + /** Proof the caller already confirmed the create; without it the core asks before the POST. */ + readonly consent?: HostedMutationConsent; } /** Formats `key: value`, padding `key:` to width 20. */ @@ -72,7 +81,7 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( input: ProjectCreateInput, ) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; @@ -120,6 +129,12 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( ...(input.templateUrl !== undefined ? { template_url: input.templateUrl } : {}), }; + if (input.consent === undefined) { + yield* requireHostedMutationConsent({ + action: `Create project "${name}" in organization ${orgId}`, + }); + } + const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; // `executeRaw` skips output decoding so the created project is emitted as the API returned it, @@ -158,20 +173,20 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( const projectUrl = `${dashboardUrlForProfile(cliSettings.profile)}/project/${id}`; yield* output.raw(`Created a new project at ${projectUrl}\n`, "stderr"); - const goFmt = Option.getOrUndefined(goOutputFlag); - if (goFmt === "json") { - yield* output.raw(encodeGoJson(created)); + const outputFlagFormat = Option.getOrUndefined(outputFlag); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson(created)); return result; } - if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(created, GO_PROJECT_RESPONSE)); + if (outputFlagFormat === "yaml") { + yield* output.raw(encodeStructYaml(created, PROJECT_RESPONSE_SHAPE)); return result; } - if (goFmt === "toml") { - yield* output.raw(encodeGoToml(created, GO_PROJECT_RESPONSE)); + if (outputFlagFormat === "toml") { + yield* output.raw(encodeStructToml(created, PROJECT_RESPONSE_SHAPE)); return result; } - if (goFmt === "env") { + if (outputFlagFormat === "env") { yield* output.raw(encodeEnv(created) + "\n"); return result; } diff --git a/apps/cli/src/command-internal/project-environment.ts b/apps/cli/src/command-internal/project-environment.ts deleted file mode 100644 index ebccf2a310..0000000000 --- a/apps/cli/src/command-internal/project-environment.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { CliProjectEnvironment } from "@supabase/config"; -import { Config, Data, Effect, FileSystem, Option, Path } from "effect"; - -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityFingerprintId, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; -import { parseDotEnv } from "./dotenv.ts"; - -/** - * Dotenv filenames to check, in precedence order, for a given `SUPABASE_ENV` value - * (`.env..local`, `.env.local` — skipped for `"test"` — `.env.`, `.env`). - * - * Fills a gap in `@supabase/config`'s `loadCliProjectEnvironment`, which only resolves this - * order for the `supabase/` directory: this module also checks the project root, and applies - * `SUPABASE_ENV`-selected filenames there too. Its own resolved values still take precedence - * over anything found here, since it's already correct for the keys it knows about. - */ -export function candidateDotenvFilenames(env: string): ReadonlyArray { - return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; -} - -export class ProjectEnvironmentError extends Data.Error<{ readonly message: string }> { - static readonly [ErrorActionabilityFingerprintId] = "ProjectEnvironmentError"; - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Reads and parses a dotenv file, or `undefined` if it doesn't exist. Delegates to - * {@link parseDotEnv} rather than a hand-rolled line scanner, so a quoted value spanning - * physical lines (a PEM/private key) parses correctly. - * - * Fails on a malformed line (not blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` - * assignment) — the caller must fail rather than silently skip it. - */ -const readDotEnvFile = Effect.fnUntraced(function* (path: string) { - const fs = yield* FileSystem.FileSystem; - if (!(yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)))) return undefined; - - const bytes = yield* fs.readFile(path).pipe( - Effect.mapError( - ({ reason }) => - new ProjectEnvironmentError({ - message: reason.cause instanceof Error ? reason.cause.message : reason.message, - }), - ), - ); - const contents = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); - return yield* Effect.try({ - try: () => parseDotEnv(contents), - catch: (cause) => - new ProjectEnvironmentError({ - message: `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, - }), - }); -}); - -/** - * Merged env-var map for `stop`/`status` to read `SUPABASE_*` overrides from, covering the - * project-root and `SUPABASE_ENV`-selected files {@link candidateDotenvFilenames} adds beyond - * `loadCliProjectEnvironment`. - * - * Only `projectEnv`'s ambient-sourced entries are layered back on top: its other, file-derived - * entries aren't necessarily higher-precedence than the env-specific files resolved here, and - * it has no notion of those filenames to tell the two apart itself. - * - * `projectEnv` is `null` when no config file was found, but dotenv loading isn't gated on - * that — fall back to deriving `/supabase` and `workdir` directly, with `process.env` - * as the ambient layer. - */ -export const resolveProjectEnvironmentValues = Effect.fn("ProjectEnvironment.resolve")(function* ( - projectEnv: CliProjectEnvironment | null, - workdir: string, - supabaseEnv?: string, -) { - const path = yield* Path.Path; - const env = - supabaseEnv || - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_ENV")).pipe( - Effect.mapError( - () => - new ProjectEnvironmentError({ - message: "failed to resolve environment variable: SUPABASE_ENV", - }), - ), - ), - ) || - "development"; - const filenames = [...new Set(candidateDotenvFilenames(env))]; - const merged: Record = {}; - let fileCount = 0; - - const supabaseDir = projectEnv?.paths.supabaseDir ?? path.join(workdir, "supabase"); - const projectRoot = projectEnv?.paths.projectRoot ?? workdir; - - // supabase/ dir first, then its parent (the project root). Within a directory, - // "never override an already-set var" means first-processed-wins, so skipping keys - // already present reproduces both orderings at once. - for (const dir of [supabaseDir, projectRoot]) { - for (const filename of filenames) { - const parsed = yield* readDotEnvFile(path.join(dir, filename)); - if (parsed === undefined) continue; - fileCount += 1; - for (const [key, value] of Object.entries(parsed)) { - if (!(key in merged)) merged[key] = value; - } - } - } - yield* Effect.annotateCurrentSpan("file.count", fileCount); - - const ambientOverrides: Record = {}; - if (projectEnv !== null) { - for (const [key, value] of Object.entries(projectEnv.values)) { - if (projectEnv.sources[key] === "ambient") { - ambientOverrides[key] = value; - } - } - } else { - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - ambientOverrides[key] = value; - } - } - } - - return { ...merged, ...ambientOverrides }; -}); diff --git a/apps/cli/src/command-internal/project-environment.unit.test.ts b/apps/cli/src/command-internal/project-environment.unit.test.ts deleted file mode 100644 index 51e9158099..0000000000 --- a/apps/cli/src/command-internal/project-environment.unit.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, layer } from "@effect/vitest"; -import type { CliProjectEnvironment } from "@supabase/config"; -import { ConfigProvider, Effect, FileSystem, Path, Tracer } from "effect"; - -import { withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import { ProjectEnvironmentError, resolveProjectEnvironmentValues } from "./project-environment.ts"; - -const project = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-project-env-" }); - const supabaseDir = path.join(root, "supabase"); - yield* fs.makeDirectory(supabaseDir, { recursive: true }); - return { fs, path, root, supabaseDir }; -}); - -function fakeProjectEnv( - { path, root, supabaseDir }: Effect.Success, - values: Record = {}, - sources: Record = {}, -): CliProjectEnvironment { - return { - paths: { - projectRoot: root, - supabaseDir, - configPath: path.join(supabaseDir, "config.toml"), - envPath: path.join(supabaseDir, ".env"), - envLocalPath: path.join(supabaseDir, ".env.local"), - }, - values, - loadedPaths: [], - // Defaults each value's source to "ambient" so callers don't need to spell it out. - sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), - }; -} - -layer(BunServices.layer)("resolveProjectEnvironmentValues", (it) => { - it.effect("returns just the already-loaded values when no extra dotenv files exist", () => - Effect.gen(function* () { - const dirs = yield* project; - const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "from-loader" }); - expect(yield* resolveProjectEnvironmentValues(projectEnv, dirs.root)).toEqual({ - SUPABASE_PROJECT_ID: "from-loader", - }); - }), - ); - - it.effect("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); - }), - ); - - it.effect("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }), - ); - - it.effect("lets already-resolved projectEnv.values win over anything discovered locally", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "ambient-project" }); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }), - ); - - it.effect("defaults SUPABASE_ENV to development when unset", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_PROJECT_ID=dev-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), - ); - - it.effect("defaults an empty SUPABASE_ENV to development", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_PROJECT_ID=dev-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "" }, body)), - ); - - it.effect("selects the SUPABASE_ENV-named file over the bare .env file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env.production.local"), - "SUPABASE_PROJECT_ID=prod-local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - yield* fs.writeFileString(path.join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "test" }, body)), - ); - - it.effect("prefers an explicit supabaseEnv argument over the configured SUPABASE_ENV", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root, "test"); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("bare-env-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("falls back to the configured SUPABASE_ENV when no supabaseEnv argument is given", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("strips quotes the same way the shared dotenv parser does", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); - }), - ); - - it.effect("ignores blank lines and comments", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); - }), - ); - - it.effect( - "preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "SUPABASE_AUTH_JWT_SECRET=long#secret\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); - }), - ); - - it.effect("still truncates an unquoted value at a whitespace-preceded inline comment", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=54323 # local\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); - }), - ); - - it.effect("strips a trailing comment after a quoted value, matching godotenv", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_PROJECT_ID="demo" # local\n'); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }), - ); - - it.effect( - "accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID: colon-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); - }), - ); - - it.effect( - "prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", - () => - Effect.gen(function* () { - // Only an "ambient" source outranks the file precedence computed locally. - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - dirs, - { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, - { SUPABASE_PROJECT_ID: ".env" }, - ); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), - ); - - it.effect("still lets a truly ambient-sourced value win over any file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - dirs, - { SUPABASE_PROJECT_ID: "ambient-project" }, - { SUPABASE_PROJECT_ID: "ambient" }, - ); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), - ); - - it.effect( - "fails on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "not a valid line\n"); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(ProjectEnvironmentError); - expect(String(error)).toMatch(/^Error: failed to parse environment file: /); - }), - ); - - it.effect("keeps a leading BOM, so a BOM-prefixed .env fails to parse", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "\uFEFFSUPABASE_PROJECT_ID=bom-project\n"); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(String(error)).toMatch(/^Error: failed to parse environment file: /); - }), - ); - - it.effect("fails with the native read error when a dotenv path is a directory", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.makeDirectory(path.join(root, ".env")); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(String(error)).toMatch(/^Error: EISDIR/); - }), - ); - - it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => - Effect.gen(function* () { - const dirs = yield* project; - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), dirs.root).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(ProjectEnvironmentError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); - }).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.make(() => - Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), - ), - ), - ), - ), - ); - - it.effect("skips a dotenv path that is a symlink loop, like a missing file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.symlink(path.join(root, ".env"), path.join(root, ".env")); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged).toEqual({}); - }), - ); - - it.effect("expands an unquoted $VAR reference to an earlier value in the same file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }), - ); - - it.effect("expands a braced ${VAR} reference in a double-quoted value", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); - }), - ); - - it.effect("does not expand variable references inside single-quoted values", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); - }), - ); - - it.effect( - "expands an unresolved bare reference to an empty string, matching Go's map zero-value", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); - }), - ); - - it.effect( - "expands an unresolved braced reference to an empty string, matching Go's map zero-value", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); - }), - ); - - it.effect( - "preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", - () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); - }), - ); - - it.effect("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); - }), - ); - - it.effect("treats a bare trailing $ with no variable name as a literal", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); - }), - ); - - it.effect( - "preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", - () => - Effect.gen(function* () { - // A quoted value spanning physical lines (e.g. a pasted PEM key) must not break - // parsing of the rest of the file. - const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); - }), - ); - - it.effect( - "records the parsed dotenv file count on one resolve span, and no span when tracing is off", - () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", - ); - const resolve = resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.withTracer(tracer), - ); - const expected = { - SUPABASE_PROJECT_ID: "supabase-dir-project", - SUPABASE_AUTH_JWT_SECRET: "dev-secret", - }; - - expect(yield* resolve.pipe(Effect.withTracerEnabled(false))).toEqual(expected); - expect(spans).toEqual([]); - - expect(yield* resolve.pipe(Effect.withTracerEnabled(true))).toEqual(expected); - expect(spans.map((span) => span.name)).toEqual(["ProjectEnvironment.resolve"]); - expect(spans[0]?.attributes.get("file.count")).toBe(2); - }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), - ); - - it.effect("counts a .env.local file once when SUPABASE_ENV is local", () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues( - fakeProjectEnv(dirs), - root, - "local", - ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("local-project"); - expect(spans[0]?.attributes.get("file.count")).toBe(1); - }), - ); - - describe("when no project was found (projectEnv is null)", () => { - // A missing config.toml must not skip dotenv loading; these cover the fallback - // that derives `/supabase` directly. - - it.effect("still reads a supabase/-dir dotenv file directly under workdir", () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect("still reads a project-root dotenv file directly under workdir", () => - Effect.gen(function* () { - const { fs, path, root } = yield* project; - yield* fs.writeFileString( - path.join(root, ".env"), - "SUPABASE_PROJECT_ID=root-fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect( - "prefers the supabase/-dir file over the project-root file, same as the non-null case", - () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env"), - "SUPABASE_PROJECT_ID=root-dir-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect("lets an ambient shell var win over a dotenv value, using process.env directly", () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=dotenv-fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", "ambient-fallback-project", body)), - ); - - it.effect("returns an empty object when workdir has no dotenv files and no ambient value", () => - Effect.gen(function* () { - const { root } = yield* project; - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - }); -}); diff --git a/apps/cli/src/command-internal/project-lifecycle-lock.integration.test.ts b/apps/cli/src/command-internal/project-lifecycle-lock.integration.test.ts new file mode 100644 index 0000000000..e38174ca5c --- /dev/null +++ b/apps/cli/src/command-internal/project-lifecycle-lock.integration.test.ts @@ -0,0 +1,474 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, FileSystem, Layer, Option, Path } from "effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as TestClock from "effect/testing/TestClock"; + +import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; +import { start } from "../commands/start/start.handler.ts"; +import type { StartFlags } from "../commands/start/start.command.ts"; +import { stop } from "../commands/stop/stop.handler.ts"; +import type { StopFlags } from "../commands/stop/stop.command.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, + sequentialExecBatch, +} from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { dockerModel, type DockerModel } from "../../tests/helpers/docker-model.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../tests/helpers/mocks.ts"; +import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; +import { DbConnection, type DbSession } from "./db-connection.service.ts"; +import { dockerRunLayer } from "./docker-run.layer.ts"; +import { DebugFlag, ExperimentalFlag, NetworkIdFlag, YesFlag } from "./global-flags.ts"; +import { ProjectLifecycleLockBusyError } from "./project-lifecycle-lock.ts"; + +const startFlags: StartFlags = { exclude: [], ignoreHealthCheck: false, preview: false }; + +const stopFlags = (overrides: Partial = {}): StopFlags => ({ + projectId: Option.none(), + backup: true, + noBackup: false, + all: Option.none(), + ...overrides, +}); + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +/** The restart path of `start` opens a database session to converge webhooks; an empty one suffices. */ +const emptyDbSession: DbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + execBatch: (statements) => sequentialExecBatch(emptyDbSession)(statements), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), +}; + +/** + * Suspends whoever calls `hold` until the test calls `Deferred.succeed(release)`. The test scope + * releases it on exit so a failed assertion cannot leave a paused rollback, which runs + * uninterruptibly, blocking cleanup. + */ +const pause = Effect.fnUntraced(function* () { + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + return { + reached, + release, + hold: Deferred.succeed(reached, undefined).pipe(Effect.andThen(Deferred.await(release))), + }; +}); + +/** + * One CLI process: its own working directory for `project_id`, its own output, and a `waiting` + * signal that completes when the process prints the lifecycle lock's waiting line. Every process + * shares the Docker daemon and `supabaseHome`, like two terminals on one machine. + */ +const makeProcess = Effect.fnUntraced(function* ( + docker: DockerModel, + supabaseHome: string, + projectId: string, + format: "text" | "json" | "stream-json" = "text", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-lock-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + `project_id = "${projectId}"\n`, + ); + + const out = mockOutput({ format }); + const waiting = yield* Deferred.make(); + const output = Layer.effect( + Output, + Effect.gen(function* () { + const base = yield* Output; + return { + ...base, + raw: (text: string, stream?: "stdout" | "stderr") => + base + .raw(text, stream) + .pipe( + Effect.tap(() => + text.startsWith("Waiting for another Supabase CLI command") + ? Deferred.succeed(waiting, undefined) + : Effect.void, + ), + ), + }; + }), + ).pipe(Layer.provide(out.layer)); + + const layer = Layer.mergeAll( + unusedStackServices, + BunServices.layer, + cliConfigValuesTestLayer, + output, + mockCommandSettings({ workdir, supabaseHome, projectId: Option.none() }), + mockTelemetryStateTracked().layer, + mockAnalytics().layer, + docker.layer, + alwaysReadyHttpClientLayer, + Layer.succeed(DbConnection, { connect: () => Effect.succeed(emptyDbSession) }), + dockerRunLayer.pipe(Layer.provide(docker.layer), Layer.provide(mockProcessControl().layer)), + mockProcessControl().layer, + mockRuntimeInfo({ platform: "linux" }), + Layer.succeed(CommandPlatformApiFactory, { + make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), + }), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(DebugFlag, false), + Layer.succeed(YesFlag, false), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + mockTty({ stdinIsTty: false }), + mockStdin(false), + ); + return { out, waiting, layer }; +}); + +type CliProcess = Effect.Success>; + +const runStart = (cli: CliProcess) => + start(startFlags).pipe(Effect.provide(cli.layer), Effect.forkChild({ startImmediately: true })); + +const runStop = (cli: CliProcess, flags: StopFlags = stopFlags()) => + stop(flags).pipe(Effect.provide(cli.layer), Effect.forkChild({ startImmediately: true })); + +/** Resolves as soon as the process either prints the waiting line or finishes. */ +const waitingOrFinished = (cli: CliProcess, fiber: Fiber.Fiber) => + Effect.raceFirst( + Deferred.await(cli.waiting).pipe(Effect.as("waiting" as const)), + Fiber.await(fiber).pipe(Effect.as("finished" as const)), + ); + +const isNetworkCreate = (args: ReadonlyArray) => + args[0] === "network" && args[1] === "create"; +const isNetworkPrune = (args: ReadonlyArray) => + args[0] === "network" && args[1] === "prune"; + +const NETWORK = "supabase_network_demo"; + +describe("project lifecycle lock", () => { + it.live( + "holds a stop of the same project until the start in flight has attached its containers", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const networkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => (isNetworkCreate(args) ? networkCreated.hold : Effect.void), + }); + const starter = yield* makeProcess(docker, home, "demo"); + const stopper = yield* makeProcess(docker, home, "demo"); + + const starting = yield* runStart(starter); + yield* Deferred.await(networkCreated.reached); + const stopping = yield* runStop(stopper); + + expect(yield* waitingOrFinished(stopper, stopping)).toBe("waiting"); + expect(stopper.out.stderrText).toContain( + "Waiting for another Supabase CLI command to finish with project demo...", + ); + expect(docker.networkNames()).toEqual([NETWORK]); + + yield* Deferred.succeed(networkCreated.release, undefined); + yield* Fiber.join(starting); + yield* Fiber.join(stopping); + expect(docker.containerNames()).toEqual([]); + expect(docker.networkNames()).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.live( + "holds stop --all until the start in flight has attached its containers", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const networkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => (isNetworkCreate(args) ? networkCreated.hold : Effect.void), + }); + const starter = yield* makeProcess(docker, home, "demo"); + const stopper = yield* makeProcess(docker, home, "other"); + + const starting = yield* runStart(starter); + yield* Deferred.await(networkCreated.reached); + const stopping = yield* runStop(stopper, stopFlags({ all: Option.some(true) })); + + expect(yield* waitingOrFinished(stopper, stopping)).toBe("waiting"); + expect(stopper.out.stderrText).toContain( + "Waiting for another Supabase CLI command to finish with local Supabase projects...", + ); + expect(docker.networkNames()).toEqual([NETWORK]); + + yield* Deferred.succeed(networkCreated.release, undefined); + yield* Fiber.join(starting); + yield* Fiber.join(stopping); + expect(docker.containerNames()).toEqual([]); + expect(docker.networkNames()).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.live( + "holds a start until stop --all has finished pruning", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const pruning = yield* pause(); + const docker = dockerModel({ + before: Effect.fnUntraced(function* (args: ReadonlyArray) { + if (isNetworkPrune(args)) yield* pruning.hold; + return undefined; + }), + }); + const stopper = yield* makeProcess(docker, home, "other"); + const starter = yield* makeProcess(docker, home, "demo"); + + const stopping = yield* runStop(stopper, stopFlags({ all: Option.some(true) })); + yield* Deferred.await(pruning.reached); + const starting = yield* runStart(starter); + + expect(yield* waitingOrFinished(starter, starting)).toBe("waiting"); + expect(starter.out.stderrText).toContain( + "Waiting for another Supabase CLI command to finish with local Supabase projects...", + ); + expect(docker.networkNames()).toEqual([]); + + yield* Deferred.succeed(pruning.release, undefined); + yield* Fiber.join(stopping); + yield* Fiber.join(starting); + expect(docker.networkNames()).toEqual([NETWORK]); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.live( + "lets starts of different projects run side by side", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const alphaNetworkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => + isNetworkCreate(args) && args.at(-1) === "supabase_network_alpha" + ? alphaNetworkCreated.hold + : Effect.void, + }); + const alpha = yield* makeProcess(docker, home, "alpha"); + const beta = yield* makeProcess(docker, home, "beta"); + + const startingAlpha = yield* runStart(alpha); + yield* Deferred.await(alphaNetworkCreated.reached); + const startingBeta = yield* runStart(beta); + + expect(yield* waitingOrFinished(beta, startingBeta)).toBe("finished"); + expect(docker.containerNames()).toContain("supabase_db_beta"); + expect(docker.containerNames()).not.toContain("supabase_db_alpha"); + + yield* Deferred.succeed(alphaNetworkCreated.release, undefined); + yield* Fiber.join(startingAlpha); + yield* Fiber.join(startingBeta); + expect(docker.containerNames()).toContain("supabase_db_alpha"); + expect(docker.networkNames()).toEqual(["supabase_network_alpha", "supabase_network_beta"]); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.live( + "holds a second start until the first start has finished rolling back", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const rollbackAtNetworkPrune = yield* pause(); + let firstStartFailed = false; + let rollbackPaused = false; + const docker = dockerModel({ + before: Effect.fnUntraced(function* (args: ReadonlyArray) { + if (args[0] === "create" && args.includes("supabase_auth_demo") && !firstStartFailed) { + firstStartFailed = true; + return { exitCode: 1, stderr: ["Error: no space left on device"] }; + } + if (isNetworkPrune(args) && !rollbackPaused) { + rollbackPaused = true; + yield* rollbackAtNetworkPrune.hold; + } + return undefined; + }), + }); + const first = yield* makeProcess(docker, home, "demo"); + const second = yield* makeProcess(docker, home, "demo"); + + const firstStarting = yield* runStart(first); + yield* Deferred.await(rollbackAtNetworkPrune.reached); + const secondStarting = yield* runStart(second); + + expect(yield* waitingOrFinished(second, secondStarting)).toBe("waiting"); + expect(docker.networkNames()).toEqual([NETWORK]); + + yield* Deferred.succeed(rollbackAtNetworkPrune.release, undefined); + expect(Exit.isFailure(yield* Fiber.await(firstStarting))).toBe(true); + yield* Fiber.join(secondStarting); + expect(docker.networkNames()).toEqual([NETWORK]); + expect(docker.containerNames()).toContain("supabase_auth_demo"); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.live( + "leaves the start in flight untouched when a waiting stop is interrupted", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const networkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => (isNetworkCreate(args) ? networkCreated.hold : Effect.void), + }); + const starter = yield* makeProcess(docker, home, "demo"); + const stopper = yield* makeProcess(docker, home, "demo"); + + const starting = yield* runStart(starter); + yield* Deferred.await(networkCreated.reached); + const stopping = yield* runStop(stopper); + expect(yield* waitingOrFinished(stopper, stopping)).toBe("waiting"); + + yield* Fiber.interrupt(stopping); + expect(docker.networkNames()).toEqual([NETWORK]); + + yield* Deferred.succeed(networkCreated.release, undefined); + yield* Fiber.join(starting); + expect(docker.containerNames()).toContain("supabase_db_demo"); + expect(docker.networkNames()).toEqual([NETWORK]); + + yield* Fiber.join(yield* runStop(stopper)); + expect(docker.containerNames()).toEqual([]); + expect(docker.networkNames()).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.effect("fails a start that outwaits the lock budget without touching the owner's stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const networkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => (isNetworkCreate(args) ? networkCreated.hold : Effect.void), + }); + const owner = yield* makeProcess(docker, home, "demo"); + const waiter = yield* makeProcess(docker, home, "demo"); + + yield* runStart(owner); + yield* Deferred.await(networkCreated.reached); + const blocked = yield* start(startFlags).pipe( + Effect.provide(waiter.layer), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + // The lock polls on the controlled clock, so step it until the waiter has polled once. + yield* Effect.raceFirst( + Effect.forever(TestClock.adjust("1 second")), + waitingOrFinished(waiter, blocked), + ); + expect(yield* waitingOrFinished(waiter, blocked)).toBe("waiting"); + yield* TestClock.adjust("10 minutes"); + + expect(yield* Fiber.join(blocked)).toBeInstanceOf(ProjectLifecycleLockBusyError); + expect(docker.networkNames()).toEqual([NETWORK]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + for (const format of ["json", "stream-json"] as const) { + it.effect(`keeps the waiting line out of ${format} output while waiting for the lock`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const networkCreated = yield* pause(); + const docker = dockerModel({ + after: (args) => (isNetworkCreate(args) ? networkCreated.hold : Effect.void), + }); + const owner = yield* makeProcess(docker, home, "demo"); + const waiter = yield* makeProcess(docker, home, "demo", format); + + yield* runStart(owner); + yield* Deferred.await(networkCreated.reached); + const blocked = yield* start(startFlags).pipe( + Effect.provide(waiter.layer), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.raceFirst(Effect.forever(TestClock.adjust("1 minute")), Fiber.await(blocked)); + + expect(yield* Fiber.join(blocked)).toBeInstanceOf(ProjectLifecycleLockBusyError); + expect(waiter.out.rawChunks).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + } + + describe("when the lock directory cannot be created", () => { + const unwritableHome = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const notADirectory = path.join(base, "file"); + yield* fs.writeFileString(notADirectory, ""); + return path.join(notADirectory, "home"); + }); + + it.effect("starts the stack without the lock", () => + Effect.gen(function* () { + const docker = dockerModel(); + const cli = yield* makeProcess(docker, yield* unwritableHome(), "demo"); + + yield* start(startFlags).pipe(Effect.provide(cli.layer)); + + expect(docker.containerNames()).toContain("supabase_db_demo"); + expect(cli.out.stderrText).not.toContain("Waiting for another Supabase CLI command"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("stops the stack without the lock", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-home-" }); + const docker = dockerModel(); + const starter = yield* makeProcess(docker, home, "demo"); + const stopper = yield* makeProcess(docker, yield* unwritableHome(), "demo"); + yield* start(startFlags).pipe(Effect.provide(starter.layer)); + expect(docker.containerNames()).toContain("supabase_db_demo"); + + yield* stop(stopFlags()).pipe(Effect.provide(stopper.layer)); + + expect(docker.containerNames()).toEqual([]); + expect(docker.networkNames()).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); +}); diff --git a/apps/cli/src/command-internal/project-lifecycle-lock.ts b/apps/cli/src/command-internal/project-lifecycle-lock.ts new file mode 100644 index 0000000000..609f176ca7 --- /dev/null +++ b/apps/cli/src/command-internal/project-lifecycle-lock.ts @@ -0,0 +1,156 @@ +import { + holdExclusiveLock, + holdLock, + holdSharedLock, + isLockBusy, + type NamespaceError, +} from "@supabase/stack/effect"; +import { + Clock, + Crypto, + Data, + Duration, + Effect, + FileSystem, + Option, + Path, + type Scope, +} from "effect"; + +import { CommandSettings } from "../config/command-settings.service.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; +import { cliProjectFilterValue } from "./docker-ids.ts"; + +const LOCKS_DIR = "locks"; +const GATE_FILE = "docker-lifecycle.lock"; +const LOCK_BUDGET = Duration.minutes(10); +const LOCK_SUBJECT = "the Docker lifecycle lock"; + +/** Another lifecycle command kept the lock for the whole wait budget. */ +export class ProjectLifecycleLockBusyError extends Data.TaggedError( + "ProjectLifecycleLockBusyError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidInput, fingerprint_suffix: "lease_held" }; + } +} + +type TakeLock = ( + file: string, + budget: Duration.Input, + subject: string, +) => Effect.Effect; + +/** + * Holds the cross-process lock that keeps lifecycle commands from pruning Docker resources another + * command is still creating, for the enclosing scope. An empty `projectId` targets every project + * (`stop --all`) and excludes all other holders; otherwise it excludes only commands for the same + * project. Take it before any Docker probe and release it after rollback. It is not reentrant, so + * never take it inside code a lock holder calls. A lock that cannot be created or opened is + * skipped with a debug line. Older binaries, raw `docker` commands, and a different `SUPABASE_HOME` + * are not covered. + */ +export const holdProjectLifecycleLock = Effect.fn("ProjectLifecycleLock.hold")(function* ( + projectId: string, +) { + const { supabaseHome } = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + const crypto = yield* Crypto.Crypto; + const debugLogger = yield* Effect.serviceOption(DebugLogger); + + const allProjects = projectId.length === 0; + const started = yield* Clock.currentTimeMillis; + let waited = false; + + const take = Effect.fnUntraced(function* ( + hold: TakeLock, + file: string, + waitingLine: string, + busyMessage: string, + ) { + yield* hold(file, Duration.zero, LOCK_SUBJECT).pipe( + Effect.catchIf(isLockBusy, () => + Effect.gen(function* () { + if (!waited) { + waited = true; + if (output.format === "text") yield* output.raw(waitingLine, "stderr"); + } + const elapsed = (yield* Clock.currentTimeMillis) - started; + yield* hold( + file, + Duration.millis(Math.max(0, Duration.toMillis(LOCK_BUDGET) - elapsed)), + LOCK_SUBJECT, + ); + }), + ), + Effect.mapError((error) => + isLockBusy(error) ? new ProjectLifecycleLockBusyError({ message: busyMessage }) : error, + ), + ); + }); + + yield* Effect.gen(function* () { + const dir = path.join(supabaseHome, LOCKS_DIR); + yield* fs.makeDirectory(dir, { recursive: true }); + const gate = path.join(dir, GATE_FILE); + + if (allProjects) { + yield* take( + holdExclusiveLock, + gate, + "Waiting for another Supabase CLI command to finish with local Supabase projects...\n", + "Another Supabase CLI command is still starting or stopping local Supabase projects. Wait for it to finish, then retry.", + ); + return; + } + + const digest = yield* crypto.digest( + "SHA-256", + new TextEncoder().encode(cliProjectFilterValue(projectId)), + ); + const key = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + yield* take( + holdSharedLock, + gate, + "Waiting for another Supabase CLI command to finish with local Supabase projects...\n", + "Another Supabase CLI command is stopping all local Supabase projects. Wait for it to finish, then retry.", + ); + yield* take( + holdLock, + path.join(dir, `docker-project-${key}.lock`), + `Waiting for another Supabase CLI command to finish with project ${projectId}...\n`, + `Another Supabase CLI command is still starting or stopping project ${projectId}. Wait for it to finish, then retry.`, + ); + }).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.annotateCurrentSpan({ "lock.outcome": "acquired" }), + onFailure: (error) => + error instanceof ProjectLifecycleLockBusyError + ? Effect.annotateCurrentSpan({ "lock.outcome": "timeout" }).pipe( + Effect.andThen(Effect.fail(error)), + ) + : (Option.isSome(debugLogger) + ? debugLogger.value.debug( + `Continuing without the Docker lifecycle lock: ${error.message}`, + ) + : Effect.void + ).pipe(Effect.andThen(Effect.annotateCurrentSpan({ "lock.outcome": "unavailable" }))), + }), + Effect.onExit(() => + Effect.annotateCurrentSpan({ + "lock.mode": allProjects ? "all" : "project", + "lock.waited": waited, + }), + ), + ); +}); diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts deleted file mode 100644 index 3e25e6d43e..0000000000 --- a/apps/cli/src/command-internal/project-target.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; -import { Data, Effect, Option } from "effect"; - -import { RemoteTarget, type RemoteTargetError } from "../config/remote-target.service.ts"; -import { - parentNotLinkedMessage, - parentRefInvalidMessage, - parentRefTypoHint, -} from "./parent-project-ref.ts"; -import { Output } from "../shared/output/output.service.ts"; -import { projectRefPicker } from "../config/remote-target.layer.ts"; -import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; -import { compose, withCondition } from "./command-permissions/model.ts"; -import { sanitizeInlineName } from "./http-errors.ts"; -import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; - -/** - * The resolved comparison/pull target for a command that accepts `--project-ref` - * (`config diff`, `config pull`, `config push`, `pull`): a project ref, plus the branch - * name/UUID it was given, if any. - */ -export interface ConfigTarget { - readonly ref: string; - readonly branch: string | undefined; -} - -/** - * Builds the four target-resolution failures {@link resolveConfigTarget} can raise. - * A single type parameter suffices because {@link configTargetErrorsFor} already unions - * the four minted classes into one type. - */ -export interface ConfigTargetErrors { - /** - * `target` was named as a branch, but no project is linked to search for - * branches under — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` - * yielded a candidate. - */ - readonly notLinked: (target: string) => TError; - /** - * `target` was named as a branch, and a parent-project candidate exists - * but is not ref-shaped — corrupt or stale linked state. - */ - readonly parentRefInvalid: (target: string) => TError; - /** `target` named a branch the parent project does not have. */ - readonly branchNotFound: (target: string) => TError; - /** The resolved branch has no project ref yet (still provisioning). */ - readonly branchNotReady: (target: string) => TError; -} - -/** The constructor shape every minted target-error class has. */ -type ConfigTargetErrorClass = new (args: { readonly message: string }) => E; - -/** - * Mints one calling command family's four target-resolution error classes from its name - * prefix, keeping `_tag`, fingerprint, and actionability family-owned from one definition. - * - * Every caller must re-export each minted class from its own `*.errors.ts` under the - * family-prefixed name; `error-tag-stability.unit.test.ts`'s static scan only sees the - * template-interpolated tags there. - */ -export function mintConfigTargetErrors(prefix: Prefix) { - class BranchNotFoundError extends Data.TaggedError(`${prefix}BranchNotFoundError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; - } - } - class BranchNotLinkedError extends Data.TaggedError(`${prefix}BranchNotLinkedError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.projectNotLinked; - } - } - class ParentRefInvalidError extends Data.TaggedError(`${prefix}ParentRefInvalidError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.relinkProject; - } - } - class BranchNotReadyError extends Data.TaggedError(`${prefix}BranchNotReadyError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; - } - } - return { BranchNotFoundError, BranchNotLinkedError, ParentRefInvalidError, BranchNotReadyError }; -} - -/** - * Wraps a family's four minted classes as the `errors` bundle {@link resolveConfigTarget} - * takes. The message text is identical across every family, so it lives here instead of - * being restated per handler. - */ -export function configTargetErrorsFor< - TNotLinked, - TParentRefInvalid, - TBranchNotFound, - TBranchNotReady, ->(classes: { - readonly notLinked: ConfigTargetErrorClass; - readonly parentRefInvalid: ConfigTargetErrorClass; - readonly branchNotFound: ConfigTargetErrorClass; - readonly branchNotReady: ConfigTargetErrorClass; -}): ConfigTargetErrors { - return { - notLinked: (target) => new classes.notLinked({ message: parentNotLinkedMessage(target) }), - parentRefInvalid: (target) => - new classes.parentRefInvalid({ message: parentRefInvalidMessage(target) }), - branchNotFound: (target) => - new classes.branchNotFound({ - message: `Branch "${sanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.${parentRefTypoHint(target)}`, - }), - branchNotReady: (target) => - new classes.branchNotReady({ - message: `Branch "${sanitizeInlineName(target)}" has no project ref yet. Wait for it to finish provisioning, then retry.`, - }), - }; -} - -/** - * What {@link resolveConfigTarget} needs to know about a branch-lookup failure to decide - * whether it was a 404. `status` is optional because only one arm of the `mapHttpError` - * failure union carries an HTTP status; every other producer has none. `_tag` is required - * so the interface isn't a TypeScript weak type. - */ -export interface ConfigTargetResolveFailure { - readonly _tag: string; - readonly status?: number | undefined; -} - -// Not folded into `mapResolveError`: `config pull` shares one mapper across the branch -// lookup and the config read, so treating every 404 as "branch not found" here would -// misclassify a config-read 404. -function reclassifyBranchNotFoundError( - cause: E, - notFoundError: C, -): Effect.Effect { - return cause.status === 404 ? Effect.fail(notFoundError) : Effect.fail(cause); -} - -/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--project-ref`, the project picker when no ref is given or linked. */ -export const configTarget = compose( - withCondition(branchRef, { flag: "project-ref", present: true }), - projectRefPicker, -); - -/** - * Resolves `--project-ref` to a {@link ConfigTarget}: a project ref, or the name/UUID of a - * branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. - * - * A UUID branch needs no parent ref, so it works unlinked; a name branch resolves the parent - * ref eagerly, before any spinner starts, so an unlinked or stale link fails immediately. - */ -export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* < - TError, - EResolve extends ConfigTargetResolveFailure, ->( - requested: Option.Option, - errors: ConfigTargetErrors, - /** Maps a branch-lookup (`GET`-by-UUID or `FIND`-by-name) transport/status failure. */ - mapResolveError: (cause: SupabaseApiError) => Effect.Effect, -) { - const output = yield* Output; - const remoteTarget = yield* RemoteTarget; - - let ref: string; - let branch: string | undefined; - if (Option.isSome(requested) && !BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { - const target = requested.value; - branch = target; - const byId = BRANCH_UUID_PATTERN.test(target); - yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); - - let parentRef: Effect.Effect; - if (byId) { - parentRef = remoteTarget - .resolveParentScoped(Option.none()) - .pipe(Effect.map((resolved) => resolved.ref)); - } else { - const parent = yield* remoteTarget.parent; - if (parent.kind === "absent") { - return yield* Effect.fail(errors.notLinked(target)); - } - if (parent.kind === "invalid") { - return yield* Effect.fail(errors.parentRefInvalid(target)); - } - parentRef = Effect.succeed(parent.ref); - } - - const resolving = - output.format === "text" ? yield* output.task("Resolving branch...") : undefined; - ref = yield* resolveBranchProjectRef(target, parentRef, { - mapGetError: mapResolveError, - mapFindError: mapResolveError, - }).pipe( - Effect.tapError(() => resolving?.fail() ?? Effect.void), - Effect.catch((cause) => reclassifyBranchNotFoundError(cause, errors.branchNotFound(target))), - ); - yield* resolving?.clear ?? Effect.void; - - // The resolved branch might not have a project ref yet (still provisioning); don't - // let an empty ref reach the config-read call. - if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { - return yield* Effect.fail(errors.branchNotReady(target)); - } - } else { - yield* Effect.annotateCurrentSpan("project_target.kind", "project"); - ({ ref } = yield* remoteTarget.resolve(requested)); - } - - yield* Effect.annotateCurrentSpan("project.ref", ref); - return { ref, branch }; -}); diff --git a/apps/cli/src/command-internal/project-target.unit.test.ts b/apps/cli/src/command-internal/project-target.unit.test.ts deleted file mode 100644 index 74db5949b4..0000000000 --- a/apps/cli/src/command-internal/project-target.unit.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Unit tests for the `mintConfigTargetErrors` factory and `configTargetErrorsFor` - * builder, independent of any family's own `*.errors.ts` file. - */ - -import { describe, expect, it } from "vitest"; - -import { actionability, ErrorActionabilityId } from "../shared/telemetry/error-actionability.ts"; -import { configTargetErrorsFor, mintConfigTargetErrors } from "./project-target.ts"; - -const PREFIX = "ConfigTestMint"; - -describe("mintConfigTargetErrors", () => { - const classes = mintConfigTargetErrors(PREFIX); - - it("tags BranchNotFoundError as `${prefix}BranchNotFoundError`", () => { - expect(new classes.BranchNotFoundError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotFoundError", - ); - }); - - it("tags BranchNotLinkedError as `${prefix}BranchNotLinkedError`", () => { - expect(new classes.BranchNotLinkedError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotLinkedError", - ); - }); - - it("tags ParentRefInvalidError as `${prefix}ParentRefInvalidError`", () => { - expect(new classes.ParentRefInvalidError({ message: "x" })._tag).toBe( - "ConfigTestMintParentRefInvalidError", - ); - }); - - it("tags BranchNotReadyError as `${prefix}BranchNotReadyError`", () => { - expect(new classes.BranchNotReadyError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotReadyError", - ); - }); - - it("declares BranchNotFoundError as actionability.invalidInput", () => { - const instance = new classes.BranchNotFoundError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.invalidInput); - }); - - it("declares BranchNotLinkedError as actionability.projectNotLinked", () => { - const instance = new classes.BranchNotLinkedError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.projectNotLinked); - }); - - it("declares ParentRefInvalidError as actionability.relinkProject", () => { - const instance = new classes.ParentRefInvalidError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.relinkProject); - }); - - it("declares BranchNotReadyError as actionability.apiStatus with a branch_not_ready fingerprint suffix", () => { - const instance = new classes.BranchNotReadyError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual({ - ...actionability.apiStatus, - fingerprint_suffix: "branch_not_ready", - }); - }); - - it("mints genuinely per-call classes, not a cached/shared set, across different prefixes", () => { - const a = mintConfigTargetErrors("ConfigTestMintA"); - const b = mintConfigTargetErrors("ConfigTestMintB"); - const aTag = new a.BranchNotFoundError({ message: "x" })._tag; - const bTag = new b.BranchNotFoundError({ message: "x" })._tag; - expect(aTag).toBe("ConfigTestMintABranchNotFoundError"); - expect(bTag).toBe("ConfigTestMintBBranchNotFoundError"); - expect(aTag).not.toBe(bTag); - }); -}); - -describe("configTargetErrorsFor", () => { - it("wraps a minted class set into constructors that produce working, correctly tagged instances", () => { - const classes = mintConfigTargetErrors(PREFIX); - const errors = configTargetErrorsFor({ - notLinked: classes.BranchNotLinkedError, - parentRefInvalid: classes.ParentRefInvalidError, - branchNotFound: classes.BranchNotFoundError, - branchNotReady: classes.BranchNotReadyError, - }); - - const error = errors.branchNotFound("some-target"); - expect(error._tag).toBe("ConfigTestMintBranchNotFoundError"); - expect(error.message).toContain("some-target"); - }); -}); diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index d50fc1e741..749d10b056 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -21,6 +21,15 @@ export const parseYesNo = (input: string): boolean | undefined => { return undefined; }; +interface YesNoOutcome { + readonly value: boolean; + /** `false` when the default was taken because nothing answered (EOF, timeout, no prompt). */ + readonly answered: boolean; +} + +const answered = (value: boolean): YesNoOutcome => ({ value, answered: true }); +const unanswered = (value: boolean): YesNoOutcome => ({ value, answered: false }); + /** * Confirm-or-default prompt shared by command handlers and shell-agnostic code alike. * `yes` echoes an affirmative answer and returns `true` immediately; non-text output @@ -30,7 +39,7 @@ export const parseYesNo = (input: string): boolean | undefined => { * wins and an empty line takes the default. Any other line declines, except under * `interactive: false`, where it takes the default. */ -export const promptYesNo = Effect.fnUntraced(function* ( +export const promptYesNoOutcome = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, label: string, @@ -41,14 +50,14 @@ export const promptYesNo = Effect.fnUntraced(function* ( const choices = defaultValue ? "Y/n" : "y/N"; if (yes) { yield* output.raw(`${label} [${choices}] y\n`, "stderr"); - return true; + return answered(true); } if (output.format !== "text" && !options.readMachineStdin) { - return defaultValue; + return unanswered(defaultValue); } const tty = yield* Tty; if (output.format !== "text" && (!interactive || tty.stdinIsTty)) { - return defaultValue; + return unanswered(defaultValue); } // Text `interactive: false` still prints the label and reads one line instead of // silently returning the default — it uses the same non-TTY read path below. @@ -60,9 +69,15 @@ export const promptYesNo = Effect.fnUntraced(function* ( yield* output.raw(`${input.trim()}\n`, "stderr"); // An unrecognised answer is never consent; under `interactive: false` the line may be // the caller's own script text, so it keeps the default. - return parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); + const value = parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); + return Option.isSome(line) ? answered(value) : unanswered(value); } - return yield* output - .promptConfirm(label, { defaultValue }) - .pipe(Effect.catchTag("NonInteractiveError", () => Effect.succeed(defaultValue))); + return yield* output.promptConfirm(label, { defaultValue }).pipe( + Effect.map(answered), + Effect.catchTag("NonInteractiveError", () => Effect.succeed(unanswered(defaultValue))), + ); }); + +/** {@link promptYesNoOutcome} reduced to the boolean every other caller wants. */ +export const promptYesNo = (...args: Parameters) => + promptYesNoOutcome(...args).pipe(Effect.map((outcome) => outcome.value)); diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 2af549adfc..74c74c61da 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -5,7 +5,7 @@ import { mockOutput, mockStdin, mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; import { Stdin } from "../shared/runtime/stdin.service.ts"; -import { parseYesNo, promptYesNo } from "./prompt-yes-no.ts"; +import { parseYesNo, promptYesNo, promptYesNoOutcome } from "./prompt-yes-no.ts"; describe("parseYesNo", () => { it("parses affirmative answers (case-insensitive, trimmed)", () => { @@ -108,6 +108,34 @@ describe("promptYesNo machine consent", () => { } }); +describe("promptYesNoOutcome piped answers", () => { + const outcome = (piped: string) => + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNoOutcome(output, false, "Confirm?", false); + }).pipe( + Effect.provide( + Layer.mergeAll(mockOutput().layer, mockStdin(false, piped), mockTty({ stdinIsTty: false })), + ), + ); + + it.effect("reports closed stdin as unanswered, taking the default", () => + Effect.gen(function* () { + expect(yield* outcome("")).toEqual({ value: false, answered: false }); + }), + ); + + it.effect.each([ + ["\n", false], + ["n\n", false], + ["y\n", true], + ] as const)("reports the piped line %j as an answer", ([piped, value]) => + Effect.gen(function* () { + expect(yield* outcome(piped)).toEqual({ value, answered: true }); + }), + ); +}); + describe("promptYesNo piped text answers", () => { const ask = (piped: string, defaultValue: boolean, interactive = true) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/removed-command.ts b/apps/cli/src/command-internal/removed-command.ts new file mode 100644 index 0000000000..563a2df1a8 --- /dev/null +++ b/apps/cli/src/command-internal/removed-command.ts @@ -0,0 +1,66 @@ +import { Data, Effect, Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../shared/runtime/command-runtime.service.ts"; +import { TelemetryState } from "../telemetry/telemetry-state.service.ts"; + +/** + * A tombstoned command path or a rejected removed flag. The caller supplies its own + * replacement suggestion; there is no closed vocabulary for it since removal + * suggestions vary per surface. + */ +export class RemovedSurfaceError extends Data.TaggedError("RemovedSurfaceError")<{ + readonly message: string; + readonly suggestion: string; + readonly kind: "command" | "flag"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.removedSurface, + fingerprint_suffix: this.kind === "flag" ? "removed_flag" : "removed_command", + }; + } +} + +/** + * Fails with a `RemovedSurfaceError` naming the invoked command path. The caller wraps the + * returned effect in `withCommandTelemetry()` then `withJsonErrorHandling`, matching + * `commit.command.ts`'s composition order, and provides a `CommandRuntime` layer for its path. + * Flushes `TelemetryState` itself (`Effect.ensuring`), since a tombstoned command never reaches + * its own handler's finalizer wiring. + */ +export const removedCommand = (suggestion: string) => + Effect.gen(function* () { + const telemetryState = yield* TelemetryState; + return yield* Effect.gen(function* () { + const commandRuntime = yield* CommandRuntime; + const command = getCommandRuntimeCommand(commandRuntime); + return yield* new RemovedSurfaceError({ + message: `supabase ${command} was removed.`, + suggestion, + kind: "command", + }); + }).pipe(Effect.ensuring(telemetryState.flush)); + }); + +/** + * Fails with a `RemovedSurfaceError` for a removed flag on an otherwise-native command. The host + * command already wraps its whole handler in `withCommandTelemetry`, so this is a bare effect. + * Flushes `TelemetryState` itself (`Effect.ensuring`) when the command provides one: call sites + * check this before their own handler reaches its own finalizer wiring further down. + */ +export const removedFlag = (flag: string, suggestion: string) => + Effect.gen(function* () { + const telemetryState = yield* Effect.serviceOption(TelemetryState); + return yield* Effect.fail( + new RemovedSurfaceError({ message: `${flag} was removed.`, suggestion, kind: "flag" }), + ).pipe( + Effect.ensuring(Option.isSome(telemetryState) ? telemetryState.value.flush : Effect.void), + ); + }); diff --git a/apps/cli/src/command-internal/removed-command.unit.test.ts b/apps/cli/src/command-internal/removed-command.unit.test.ts new file mode 100644 index 0000000000..b5d2935726 --- /dev/null +++ b/apps/cli/src/command-internal/removed-command.unit.test.ts @@ -0,0 +1,103 @@ +import { BunCrypto } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; +import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; +import { mockTelemetryStateTracked } from "../../tests/helpers/command-mocks.ts"; +import { removedCommand, removedFlag, RemovedSurfaceError } from "./removed-command.ts"; + +describe("RemovedSurfaceError actionability", () => { + it("classifies a removed command with the :removed_command fingerprint suffix", () => { + const classified = classifyCliErrorActionability( + new RemovedSurfaceError({ + message: "supabase db branch create was removed.", + suggestion: "Use `supabase branches --help` instead.", + kind: "command", + }), + ); + expect(classified).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + has_suggestion: true, + suggestion_type: "run_command", + }); + expect(classified.error_fingerprint).toBe("tag:RemovedSurfaceError:removed_command"); + }); + + it("classifies a removed flag with the :removed_flag fingerprint suffix", () => { + const classified = classifyCliErrorActionability( + new RemovedSurfaceError({ + message: "--use-pg-schema was removed.", + suggestion: "Use the default pg-delta engine or --use-migra.", + kind: "flag", + }), + ); + expect(classified).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + has_suggestion: true, + suggestion_type: "run_command", + }); + expect(classified.error_fingerprint).toBe("tag:RemovedSurfaceError:removed_flag"); + }); + + it("classifies a field-less probe instance without throwing", () => { + const probe = Object.create(RemovedSurfaceError.prototype) as RemovedSurfaceError; + expect(() => classifyCliErrorActionability(probe)).not.toThrow(); + const classified = classifyCliErrorActionability(probe); + expect(classified.error_fingerprint.endsWith(":removed_command")).toBe(true); + expect(classified).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + }); + }); +}); + +describe("TelemetryState flush", () => { + it.effect("flushes on a removedCommand failure", () => { + const telemetry = mockTelemetryStateTracked(); + return removedCommand("Use `supabase branches --help` instead.").pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(telemetry.flushCount).toBe(1); + }), + ), + Effect.provide( + Layer.mergeAll( + commandRuntimeLayer(["db", "branch", "create"]).pipe(Layer.provide(BunCrypto.layer)), + telemetry.layer, + ), + ), + ); + }); + + it.effect("flushes on a removedFlag failure", () => { + const telemetry = mockTelemetryStateTracked(); + return removedFlag("--use-pg-schema", "Use the default pg-delta engine or --use-migra.").pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(telemetry.flushCount).toBe(1); + }), + ), + Effect.provide(telemetry.layer), + ); + }); + + it.effect("fails a removedFlag for a command that provides no TelemetryState", () => + removedFlag("--create-ticket", "Report CLI problems with `supabase issue bug`.").pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(RemovedSurfaceError); + expect(error.message).toBe("--create-ticket was removed."); + }), + ), + ), + ); +}); diff --git a/apps/cli/src/command-internal/resolve-token.unit.test.ts b/apps/cli/src/command-internal/resolve-token.unit.test.ts index 8e2cb29fd1..e52ce3b99c 100644 --- a/apps/cli/src/command-internal/resolve-token.unit.test.ts +++ b/apps/cli/src/command-internal/resolve-token.unit.test.ts @@ -20,7 +20,6 @@ const settings = (accessToken: Option.Option>) => workdir: "/tmp/supabase-cli-resolve-token", explicitWorkdir: false, workdirEnvValue: Option.none(), - dbPassword: Option.none(), githubToken: Option.none(), userAgent: "SupabaseCLI/test", }); diff --git a/apps/cli/src/command-internal/resolved-config-context.ts b/apps/cli/src/command-internal/resolved-config-context.ts new file mode 100644 index 0000000000..dfd48e1982 --- /dev/null +++ b/apps/cli/src/command-internal/resolved-config-context.ts @@ -0,0 +1,86 @@ +import type { CliConfig } from "@supabase/config"; +import { Effect, Option, Predicate } from "effect"; + +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; +import { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; +import { configParseCauseMessage } from "../shared/output/normalize-error.ts"; +import { getHostname } from "./hostname.ts"; + +/** The resolved config with the materialized config, project env values and declared document its readers take. */ +interface ResolvedConfigContext { + readonly resolvedConfig: ResolvedCliConfig; + readonly config: CliConfig; + readonly projectEnvValues: Readonly>; + readonly document: Record | undefined; +} + +/** Loads the resolved config for a command that targets no project, so no `[remotes.*]` block applies. */ +export const loadResolvedConfigContext = Effect.fn("ResolvedConfigContext.load")(function* ( + workdir: string, + projectRef: Option.Option = Option.none(), +) { + const values = yield* CliConfigValues; + const resolvedConfig = yield* values.load({ workdir, projectRef }); + return { + resolvedConfig, + config: resolvedConfig.materialized.config, + projectEnvValues: resolvedConfig.projectEnvValues, + document: resolvedConfig.loaded.document, + } satisfies ResolvedConfigContext; +}); + +/** A {@link ResolvedConfigContext} plus the machine hostname and sanitized project id local Docker naming needs. */ +export interface LocalResolvedConfigContext extends ResolvedConfigContext { + readonly hostname: string; + readonly projectId: string; +} + +export const loadLocalResolvedConfigContext = Effect.fn("LocalResolvedConfigContext.load")( + function* (workdir: string, projectRef: Option.Option = Option.none()) { + const context = yield* loadResolvedConfigContext(workdir, projectRef); + const hostname = yield* getHostname(context.resolvedConfig).pipe( + Effect.mapError( + (cause) => + new CliConfigLoadError({ message: `failed to resolve hostname: ${cause.message}` }), + ), + ); + const projectId = (yield* context.resolvedConfig.get(CliConfigKeys.projectId)).value; + return { ...context, hostname, projectId } satisfies LocalResolvedConfigContext; + }, +); + +/** `auth.passkey` and `auth.webauthn` exist only when their tables do; an env override never creates one. */ +export const resolvePasskeyWebauthn = Effect.fn("ResolvedConfigContext.passkeyWebauthn")(function* ( + resolvedConfig: ResolvedCliConfig, +) { + const passkeyEnabled = resolvedConfig.declares("auth.passkey") + ? (yield* resolvedConfig.get(CliConfigKeys.auth.passkey.enabled)).value + : undefined; + const webauthn = resolvedConfig.declares("auth.webauthn") + ? { + rpId: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpId)).value, + rpDisplayName: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpDisplayName)).value, + rpOrigins: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpOrigins)).value, + } + : undefined; + return { passkeyEnabled, webauthn }; +}); + +/** The failure text commands report when the resolved config cannot be loaded. */ +export const describeConfigLoadFailure = (cause: unknown): string => + Predicate.isTagged(cause, "CliConfigParseError") && "cause" in cause + ? `failed to read config: ${configParseCauseMessage(cause.cause)}` + : typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause._tag === "CliConfigValueError" || + cause._tag === "CliConfigFlagConflictError" || + cause._tag === "CliConfigLoadError" || + cause._tag === "CliConfigRemovedKeyError" || + cause._tag === "CliConfigRemovedEnvError") && + "message" in cause && + typeof cause.message === "string" + ? cause.message + : `failed to read config: ${String(cause)}`; diff --git a/apps/cli/src/command-internal/resolved-config-context.unit.test.ts b/apps/cli/src/command-internal/resolved-config-context.unit.test.ts new file mode 100644 index 0000000000..893c5dc73d --- /dev/null +++ b/apps/cli/src/command-internal/resolved-config-context.unit.test.ts @@ -0,0 +1,98 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { mockOutput, processEnvLayer } from "../../tests/helpers/mocks.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { loadLocalResolvedConfigContext } from "./resolved-config-context.ts"; + +const tempRoot = useTempWorkdir("supabase-resolved-config-context-"); + +const writeProjectEnv = (workdir: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, ".env"), contents); + }).pipe(Effect.provide(BunServices.layer)); + +/** Runs against a shell environment of exactly `env`, with the real config service and an empty Docker config dir. */ +const layerWithShellEnv = (workdir: string, env: Readonly> = {}) => + Layer.fresh( + Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), + ), + ), + ), + processEnvLayer({ DOCKER_CONFIG: `${workdir}/docker-config`, ...env }), + ), + ); + +describe("loadLocalResolvedConfigContext hostname", () => { + it.effect("reads SUPABASE_SERVICES_HOSTNAME from a project .env", () => + Effect.gen(function* () { + const workdir = tempRoot.current; + yield* writeProjectEnv(workdir, "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n"); + + return yield* loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("dotenv-host"); + }), + Effect.provide(layerWithShellEnv(workdir)), + ); + }), + ); + + it.effect("prefers a shell SUPABASE_SERVICES_HOSTNAME over a project .env", () => + Effect.gen(function* () { + const workdir = tempRoot.current; + yield* writeProjectEnv(workdir, "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n"); + + return yield* loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("shell-host"); + }), + Effect.provide(layerWithShellEnv(workdir, { SUPABASE_SERVICES_HOSTNAME: "shell-host" })), + ); + }), + ); + + it.effect("ignores a project .env DOCKER_HOST when discovering the daemon", () => + Effect.gen(function* () { + const workdir = tempRoot.current; + yield* writeProjectEnv(workdir, "DOCKER_HOST=tcp://x:2375\n"); + + return yield* loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("127.0.0.1"); + }), + Effect.provide(layerWithShellEnv(workdir)), + ); + }), + ); + + it.effect("takes the daemon host from a shell DOCKER_HOST", () => + Effect.gen(function* () { + const workdir = tempRoot.current; + yield* writeProjectEnv(workdir, "DOCKER_HOST=tcp://project-dotenv-host:2375\n"); + + return yield* loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("shell-daemon-host"); + }), + Effect.provide(layerWithShellEnv(workdir, { DOCKER_HOST: "tcp://shell-daemon-host:2375" })), + ); + }), + ); +}); diff --git a/apps/cli/src/command-internal/schema-flags.ts b/apps/cli/src/command-internal/schema-flags.ts index 9aa44377ce..36b014a822 100644 --- a/apps/cli/src/command-internal/schema-flags.ts +++ b/apps/cli/src/command-internal/schema-flags.ts @@ -1,8 +1,7 @@ /** - * Normalizes a repeated `--schema` flag into a flat list, matching the CSV-per-occurrence - * parsing every `--schema`-accepting delegated Go subprocess expects (shared logic lives in + * Normalizes a repeated `--schema` flag into a flat list (shared logic lives in * `string-slice-flag.ts`). Also provides `schemaToCsvField`, the CSV re-encoder used when - * forwarding `--schema` back to that subprocess. + * printing a `--schema` value back into a suggested shell command. * * Shared by `gen types`, `db lint`, `db dump`, `db pull`, `db diff`, and `db schema * {generate,sync}`. @@ -13,9 +12,10 @@ export { StringSliceFlagParseError as SchemaFlagParseError }; export const parseSchemaFlags = parseStringSliceFlag; -// Whether a CSV field must be quoted (matches the `encoding/csv` writer a delegated Go -// subprocess re-parses): never quote the empty string; always quote `\.`; quote when the -// field contains `,`, `"`, `\r`, or `\n`; otherwise quote when the first rune is whitespace. +// Whether a CSV field must be quoted (so a printed +// suggestion round-trips through the same CSV parsing rules `--schema` values use): never quote +// the empty string; always quote `\.`; quote when the field contains `,`, `"`, `\r`, or `\n`; +// otherwise quote when the first character is whitespace. function fieldNeedsQuotes(field: string): boolean { if (field === "") return false; if (field === "\\.") return true; @@ -26,9 +26,8 @@ function fieldNeedsQuotes(field: string): boolean { /** * Serializes a single parsed schema value back into one CSV field — the inverse of * `readAsCSVStrict` for one element. A schema parsed from `--schema '"tenant,one"'` is the - * single value `tenant,one`; forwarding it raw to a delegated Go subprocess would let pflag's - * `StringSlice` re-parse it as CSV and split it into two schemas, so this re-encodes it to - * keep it one field when rebuilding argv for `db diff`/`db pull`. + * single value `tenant,one`; printing it raw into a suggested `--schema` command would let the + * next CSV parse re-split it into two schemas, so this re-encodes it to keep it one field. */ export function schemaToCsvField(value: string): string { if (!fieldNeedsQuotes(value)) return value; diff --git a/apps/cli/src/command-internal/schema-flags.unit.test.ts b/apps/cli/src/command-internal/schema-flags.unit.test.ts index 54d19e95f4..12dd8e8309 100644 --- a/apps/cli/src/command-internal/schema-flags.unit.test.ts +++ b/apps/cli/src/command-internal/schema-flags.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseSchemaFlags, SchemaFlagParseError, schemaToCsvField } from "./schema-flags.ts"; -describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { +describe("parseSchemaFlags", () => { it("splits unquoted comma-separated values", () => { expect(parseSchemaFlags(["public,private"])).toEqual(["public", "private"]); }); @@ -34,7 +34,7 @@ describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { expect(parseSchemaFlags([])).toEqual([]); }); - it("preserves whitespace (Go does not trim)", () => { + it("preserves whitespace (no trimming)", () => { expect(parseSchemaFlags([" public , private "])).toEqual([" public ", " private "]); }); @@ -63,7 +63,7 @@ describe("schemaToCsvField (inverse — re-encode one value as a CSV field)", () expect(schemaToCsvField("public")).toBe("public"); }); - it("leaves the empty string unquoted (Go csv.Writer)", () => { + it("leaves the empty string unquoted", () => { expect(schemaToCsvField("")).toBe(""); }); diff --git a/apps/cli/src/command-internal/scratch-paths.ts b/apps/cli/src/command-internal/scratch-paths.ts index 2ea3189c26..db9d3a1697 100644 --- a/apps/cli/src/command-internal/scratch-paths.ts +++ b/apps/cli/src/command-internal/scratch-paths.ts @@ -1,7 +1,7 @@ import { Crypto, Effect, Path } from "effect"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; +import { legacyProjectStatePaths } from "../shared/config/legacy-project-state.paths.ts"; import { usesStackState } from "./stack-backend.ts"; /** Host scratch for bundler output and downloads: `$SUPABASE_HOME/tmp` in stack state, else `supabase/.temp`. */ diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 3d0b307e4d..8e0ced02b7 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -1,16 +1,15 @@ import { type CliConfig, CliConfigSchema } from "@supabase/config/effect"; -import { loadCliConfig, type InternalLoadCliConfigOptions } from "@supabase/config/internal"; import { BunPath } from "@effect/platform-bun"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../shared/output/output.service.ts"; import { resolveYesWithProjectEnv } from "./global-flags.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { bold, yellow } from "./colors.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; -import { relativeConfigPath } from "./workdir-project.ts"; -import { shouldSearchAncestors } from "./workdir-search.ts"; +import { describeConfigLoadFailure, loadResolvedConfigContext } from "./resolved-config-context.ts"; +import { isConfigValueFailure } from "./config-value-passthrough.ts"; +import { loadCliProjectEnvFiles } from "../shared/config/cli-config-env.ts"; import { promptYesNo } from "./prompt-yes-no.ts"; import { resolveStorageCredentials, @@ -33,8 +32,12 @@ import { isVectorBucketsFeatureNotEnabled, } from "../commands/seed/buckets/buckets.classify.ts"; import { SeedConfigLoadError } from "../commands/seed/buckets/buckets.errors.ts"; +import { describeConfigParseError } from "./workdir-project.ts"; import { bucketObjectKey } from "../commands/seed/buckets/buckets.upload.ts"; -import { configParseCauseMessage } from "../shared/output/normalize-error.ts"; + +// Re-exported so `stack start` can report a project-context config-load failure with the +// same tag `seedBucketsRun` itself fails with, without importing across command boundaries. +export { SeedConfigLoadError }; const CONFIG_PATH = "supabase/config.toml"; const UPLOAD_CONCURRENCY = 5; @@ -108,7 +111,12 @@ const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); * `emitSummary` gates the stdout summary; `interactive` (default `true`) gates overwrite/prune * prompts. The caller owns project-ref resolution, cache writes, and telemetry. */ -export const seedBucketsRun = Effect.fnUntraced(function* (opts: { +export const seedBucketsRun = Effect.fnUntraced(function* < + EA = never, + RA = never, + EC = never, + RC = never, +>(opts: { readonly projectRef: string; readonly emitSummary: boolean; readonly interactive?: boolean; @@ -120,7 +128,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { */ readonly yes?: boolean; /** - * Skips this function's own `loadCliConfig` reload in favor of a config the caller already + * Skips this function's own resolved config load in favor of a config the caller already * resolved through its own nested-env walk, so a fresh reload here can't drop an override * that exists only in the shell/dotenv, not in `config.toml`. */ @@ -149,6 +157,13 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { * would swallow the next line of a `curl … | bash` script. Declined prunes say so instead. */ readonly promptless?: boolean; + /** Announces the local Storage endpoint as the run's target once its URL is known. */ + readonly announceLocalEndpoint?: (baseUrl: string) => Effect.Effect; + /** + * Asks for consent to the hosted plan's writes before the first one. One answer covers every + * bucket overwrite and prune, so no per-bucket prompt follows it. + */ + readonly confirmHostedWrites?: (details: ReadonlyArray) => Effect.Effect; }) { const output = yield* Output; const cliSettings = yield* CommandSettings; @@ -156,44 +171,52 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const posixPath = yield* Effect.provide(Path.Path, BunPath.layerPosix); - const projectEnvValues = opts.projectEnvValues ?? (yield* loadProjectEnv(fs, path, workdir)); - // `--yes` OR `SUPABASE_YES`. - const yes = opts.yes ?? (yield* resolveYesWithProjectEnv(projectEnvValues)); const { projectRef, emitSummary } = opts; const interactive = opts.interactive ?? true; const promptless = opts.promptless ?? false; - // Loads config.toml, merging `[remotes.*]` overrides for `--linked`; skipped when the - // caller already supplied `resolvedConfig`. - // An explicit `opts.workdir` is the exact project root; only the caller's own workdir - // (`cliSettings.workdir`) may still search ancestors for `config.toml`. - const search = opts.workdir === undefined && shouldSearchAncestors(cliSettings); - const loadOptions: InternalLoadCliConfigOptions = - projectRef !== "" - ? { projectRef, goViperCompat: true, search } - : { goViperCompat: true, search }; - const loaded = + // Skipped when the caller already supplied `resolvedConfig`. A missing config file behaves as + // the embedded defaults, not an early exit: local + no-config falls into the no-op + // short-circuit below, while `--linked` + no-config still falls through to the remote path so + // auth/project/API failures surface. + const context = opts.resolvedConfig !== undefined - ? null - : yield* loadCliConfig(workdir, loadOptions).pipe( + ? undefined + : yield* loadResolvedConfigContext( + workdir, + projectRef === "" ? Option.none() : Option.some(projectRef), + ).pipe( Effect.catchTag( "CliConfigParseError", (cause) => new SeedConfigLoadError({ - message: `failed to parse ${relativeConfigPath(workdir, cause.path)}: ${configParseCauseMessage(cause.cause)}`, + message: describeConfigParseError(workdir, cause), }), ), + Effect.mapError((cause) => + cause instanceof SeedConfigLoadError || isConfigValueFailure(cause) + ? cause + : new SeedConfigLoadError({ message: describeConfigLoadFailure(cause) }), + ), ); - // A missing config file behaves as embedded defaults, not an early exit: local + no-config - // falls into the no-op short-circuit below, while `--linked` + no-config still falls - // through to the remote path so auth/project/API failures surface. - const config = - opts.resolvedConfig?.config ?? (loaded === null ? decodeDefaultCliConfig({}) : loaded.config); - const document = opts.resolvedConfig?.document ?? (loaded === null ? undefined : loaded.document); + const projectEnvValues = + opts.projectEnvValues ?? + context?.projectEnvValues ?? + (yield* loadCliProjectEnvFiles(workdir).pipe( + Effect.map((loaded) => loaded.values), + Effect.mapError((cause) => new SeedConfigLoadError({ message: cause.message })), + )); + // `--yes` OR `SUPABASE_YES`. + const yes = opts.yes ?? (yield* resolveYesWithProjectEnv(projectEnvValues)); + const config = opts.resolvedConfig?.config ?? context?.config ?? decodeDefaultCliConfig({}); + const document = opts.resolvedConfig?.document ?? context?.document; // Printed whenever a `[remotes.*]` block matched the linked ref; stderr in all output modes. - if (loaded !== null && loaded.appliedRemote !== undefined) { - yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + const appliedRemote = Option.getOrUndefined( + context?.resolvedConfig.appliedRemote ?? Option.none(), + ); + if (appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${appliedRemote}]\n`, "stderr"); } const bucketsConfig = config.storage.buckets ?? {}; const bucketNames = Object.keys(bucketsConfig); @@ -227,7 +250,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // consults these legacy-only inputs, so it skips this validation entirely. const backend = yield* currentStackBackend; if (backend.kind !== "stack") { - yield* validateLocalStorageConfig(config, projectEnvValues); + yield* validateLocalStorageConfig(); } if (emitSummary && output.format !== "text") { yield* output.success("", { ...emptySummary() }); @@ -236,13 +259,10 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { } // Build the Storage service-gateway client (local or remote). - const credentials = - opts.credentials ?? - (yield* resolveStorageCredentials({ - projectRef, - config, - projectEnvValues, - })); + const credentials = opts.credentials ?? (yield* resolveStorageCredentials({ projectRef })); + if (projectRef === "" && opts.announceLocalEndpoint !== undefined) { + yield* opts.announceLocalEndpoint(credentials.baseUrl); + } // Gateway operations use plain `globalThis.fetch` instead of the DoH fetch, with the Kong CA in // `FetchHttpClient.RequestInit` for local + https; the api-keys lookup in @@ -256,16 +276,36 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const summary = emptySummary(); - yield* upsertBuckets(output, yes, interactive, promptless, gateway, bucketPropsByName, summary); + const existingBuckets = yield* gateway.listBuckets; + + // A hosted run never prompts per item: one consent covers every overwrite and prune. + const confirmHostedWrites = projectRef === "" ? undefined : opts.confirmHostedWrites; + if (confirmHostedWrites !== undefined) { + const details = yield* planHostedWrites(gateway, config, bucketPropsByName, existingBuckets); + if (details.length > 0) yield* confirmHostedWrites(details); + } + const approved = confirmHostedWrites !== undefined || yes; + const skipPrompts = confirmHostedWrites !== undefined || promptless; + + yield* upsertBuckets( + output, + approved, + interactive, + skipPrompts, + gateway, + existingBuckets, + bucketPropsByName, + summary, + ); // Upsert analytics buckets (remote --linked only). if (config.storage.analytics.enabled && projectRef !== "") { yield* output.raw("Updating analytics buckets...\n", "stderr"); yield* upsertAnalyticsBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, Object.keys(config.storage.analytics.buckets), summary, @@ -277,9 +317,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { yield* output.raw("Updating vector buckets...\n", "stderr"); yield* upsertVectorBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, vectorBucketNames, summary, @@ -336,6 +376,8 @@ const computeBucketProps = ( }), }); +type ExistingBucket = { readonly id: string; readonly name: string }; + // `propsByName` is precomputed and size-validated before this runs. const upsertBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -343,10 +385,10 @@ const upsertBuckets = Effect.fnUntraced(function* ( interactive: boolean, promptless: boolean, gateway: StorageGateway, + existing: ReadonlyArray, propsByName: ReadonlyMap, summary: SeedSummary, ) { - const existing = yield* gateway.listBuckets; const byName = new Map(existing.map((b) => [b.name, b.id])); for (const [name, props] of propsByName) { @@ -474,6 +516,71 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( } }); +const joinNames = (names: ReadonlyArray): string => names.join(", "); + +/** + * The hosted writes `seedBucketsRun` is about to make, as consent detail lines; empty when it + * would write nothing. Reads the remote bucket lists but changes nothing. + */ +const planHostedWrites = Effect.fnUntraced(function* ( + gateway: StorageGateway, + config: CliConfig, + propsByName: ReadonlyMap, + existingBuckets: ReadonlyArray, +) { + const details: Array = []; + const existing = new Set(existingBuckets.map((bucket) => bucket.name)); + const configured = [...propsByName.keys()]; + const create = configured.filter((name) => !existing.has(name)); + const overwrite = configured.filter((name) => existing.has(name)); + if (create.length > 0) details.push(`Create buckets: ${joinNames(create)}`); + if (overwrite.length > 0) + details.push(`Overwrite the properties of buckets: ${joinNames(overwrite)}`); + + const withObjects = Object.entries(config.storage.buckets ?? {}) + .filter(([, bucket]) => bucket.objects_path.length > 0) + .map(([name]) => name); + if (withObjects.length > 0) { + details.push( + `Upload objects, overwriting existing ones, to buckets: ${joinNames(withObjects)}`, + ); + } + + if (config.storage.analytics.enabled) { + const declared = Object.keys(config.storage.analytics.buckets); + const present = new Set(yield* gateway.listAnalyticsBuckets); + const created = declared.filter((name) => !present.has(name)); + const pruned = [...present].filter((name) => !declared.includes(name)); + if (created.length > 0) details.push(`Create analytics buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete analytics buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + + const declaredVector = Object.keys(config.storage.vector.buckets); + if (config.storage.vector.enabled && declaredVector.length > 0) { + const listed = yield* gateway.listVectorBuckets.pipe( + Effect.map(Option.some), + Effect.catch((error) => + isVectorBucketsFeatureNotEnabled(error.message) || + isLocalVectorBucketsUnavailable(error.message) + ? Effect.succeed(Option.none>()) + : Effect.fail(error), + ), + ); + if (Option.isSome(listed)) { + const present = new Set(listed.value); + const created = declaredVector.filter((name) => !present.has(name)); + const pruned = listed.value.filter((name) => !declaredVector.includes(name)); + if (created.length > 0) details.push(`Create vector buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete vector buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + } + return details; +}); + /** * On `FeatureNotEnabled` or local-unavailable errors, prints the matching warning and * continues (object upload still runs); any other error propagates. diff --git a/apps/cli/src/command-internal/seed-ops.unit.test.ts b/apps/cli/src/command-internal/seed-ops.unit.test.ts index 6fc7a4859a..0b9c118b8f 100644 --- a/apps/cli/src/command-internal/seed-ops.unit.test.ts +++ b/apps/cli/src/command-internal/seed-ops.unit.test.ts @@ -44,22 +44,20 @@ const tempDir = (prefix: string) => // (`../../../shared/path-match.ts`) end to end; pattern-matching semantics are covered by // `path-match.unit.test.ts`. layer(BunServices.layer)("getPendingSeeds (glob character classes)", (it) => { - it.effect( - "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempDir("seed-glob-"); - yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); - yield* fs.writeFileString(path.join(dir, "b.sql"), "select 2;"); - const { session } = fakeSeedSession(); - // `[!a]` is a positive class of `!` and `a`; only a leading `^` negates. - const pending = yield* getPendingSeeds(session, fs, path, ["[!a].sql"], dir); - expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); - }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)), + it.effect("treats a leading `!` in a bracket class as literal, not negation", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-glob-"); + yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); + // `[!a]` is a positive class of `!` and `a`; only a leading `^` negates. + const pending = yield* getPendingSeeds(session, fs, path, ["[!a].sql"], dir); + expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)), ); it.effect( - "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", + "warns the bad-pattern message for an unterminated bracket class, not a bogus no-match", () => { // An unclosed `[` is malformed, so `fs.Glob` reports a syntax error, not a bogus // "no files matched" for a well-formed-but-empty pattern. @@ -109,7 +107,7 @@ layer(BunServices.layer)("seedData (dirty parse)", (it) => { ); it.effect( - "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempDir("seed-scanner-"); @@ -121,7 +119,7 @@ layer(BunServices.layer)("seedData (dirty parse)", (it) => { { path: "big.sql", hash: "newhash", dirty: false }, ]).pipe(Effect.flip); expect(error.message).toBe( - "bufio.Scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", + "scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", ); expect(calls.some((c) => c.sql.includes("select"))).toBe(false); }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), diff --git a/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts new file mode 100644 index 0000000000..0ef8cc2bf7 --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts @@ -0,0 +1,96 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { planDbSeed } from "./seed-remote-consent.ts"; + +const REF = "abcdefghijklmnopqrst"; + +const plan = ( + toml: string, + options: { + readonly projectRef?: Option.Option; + readonly env?: Record; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "seed-plan-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), toml); + const configValues = yield* CliConfigValues; + const resolved = yield* configValues.load({ + workdir, + projectRef: options.projectRef ?? Option.some(REF), + }); + return yield* planDbSeed(resolved, { workdir, ref: REF }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + configValuesLayer({ env: options.env }), + mockCommandSettings({ workdir: "/unused" }), + BunServices.layer, + ), + ), + ); + +const FILES = ["supabase/seed.sql", "supabase/seed_users.sql"]; + +describe("planDbSeed", () => { + it.effect("describes the files without naming a remote when none matched", () => + Effect.gen(function* () { + const { input, detail } = yield* plan("", { projectRef: Option.none() }); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files"); + expect(detail(FILES.slice(0, 1))).toBe("seed 1 file"); + }), + ); + + it.effect("names the matched remote and what turned seeding on", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`, { + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + }); + expect(input.enabled).toBe(true); + expect(input.consent).toMatchObject({ remote: "prod", ref: REF }); + expect(input.consent?.enabledBy).toContain("SUPABASE_DB_SEED_ENABLED"); + expect(detail(FILES)).toBe( + `seed 2 files into [remotes.prod] (seeding enabled by ${input.consent?.enabledBy})`, + ); + }), + ); + + it.effect("omits the origin when the remote enables seeding itself", () => + Effect.gen(function* () { + const { input, detail } = yield* plan( + `[remotes.prod]\nproject_id = "${REF}"\n[remotes.prod.db.seed]\nenabled = true\n`, + ); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files into [remotes.prod]"); + }), + ); + + it.effect("has no detail when a matched remote leaves seeding off", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`); + expect(input.enabled).toBe(false); + expect(detail(FILES)).toBeUndefined(); + }), + ); + + it.effect("has no detail when seeding is off or there is nothing to seed", () => + Effect.gen(function* () { + const off = yield* plan("[db.seed]\nenabled = false\n"); + expect(off.input.enabled).toBe(false); + expect(off.detail(FILES)).toBeUndefined(); + + const none = yield* plan(""); + expect(none.detail([])).toBeUndefined(); + }), + ); +}); diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts new file mode 100644 index 0000000000..70b6a60ecc --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -0,0 +1,68 @@ +import { Effect, Option, Path, Predicate } from "effect"; + +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { describeCliConfigOrigin } from "../config/cli-config-key.ts"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; + +/** A seed run whose target matched a `[remotes.*]` block that did not turn seeding on itself. */ +interface SeedConsentTarget { + readonly ref: string; + readonly remote: string; + /** What turned seeding on, as `describeCliConfigOrigin` words it. */ + readonly enabledBy: string; +} + +/** The effective `[db.seed]` values a command acts on, plus the remote that did not enable them. */ +interface DbSeedInput { + readonly enabled: boolean; + readonly sqlPaths: ReadonlyArray; + readonly consent: SeedConsentTarget | undefined; +} + +/** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ +export interface DbSeedPlan { + readonly input: DbSeedInput; + /** `undefined` when seeding is off or `files` is empty. */ + readonly detail: (files: ReadonlyArray) => string | undefined; +} + +const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: string): boolean => { + const block = resolvedConfig.loaded.interpolatedRemotes?.[remote]; + const declared = + Predicate.hasProperty(block, "db") && + Predicate.hasProperty(block.db, "seed") && + Predicate.hasProperty(block.db.seed, "enabled") + ? block.db.seed.enabled + : undefined; + return CliConfigKeys.db.seed.enabled.codec.fromConfig(declared) === true; +}; + +/** Reads `[db.seed]` from the resolved config; one consent carries {@link DbSeedPlan.detail}. */ +export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( + resolvedConfig: ResolvedCliConfig, + target: { readonly workdir: string; readonly ref: string }, +) { + const path = yield* Path.Path; + const enabled = yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled); + const sqlPaths = yield* resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths); + const remote = Option.getOrUndefined(resolvedConfig.appliedRemote); + const consent: SeedConsentTarget | undefined = + remote === undefined || remoteDeclaresSeedEnabled(resolvedConfig, remote) + ? undefined + : { + ref: target.ref, + remote, + enabledBy: describeCliConfigOrigin(enabled.origin, { workdir: target.workdir, path }), + }; + const input: DbSeedInput = { enabled: enabled.value, sqlPaths: sqlPaths.value, consent }; + return { + input, + detail: (files) => { + if (!input.enabled || files.length === 0) return undefined; + const noun = files.length === 1 ? "file" : "files"; + const into = remote === undefined ? "" : ` into [remotes.${remote}]`; + const enabledBy = consent === undefined ? "" : ` (seeding enabled by ${consent.enabledBy})`; + return `seed ${files.length} ${noun}${into}${enabledBy}`; + }, + } satisfies DbSeedPlan; +}); diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 38c439bf5d..f15a8ff9cc 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -76,33 +76,31 @@ layer(BunServices.layer)("applySeedFiles seed glob", (it) => { }), ); - it.effect( - "expands a matched directory to its sorted, regular .sql files (Go's Glob.SQLFiles)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempDir("seed-"); - yield* fs.makeDirectory(path.join(dir, "seeds")); - yield* fs.writeFileString(path.join(dir, "seeds", "b.sql"), "insert into t values (2);"); - yield* fs.writeFileString(path.join(dir, "seeds", "a.sql"), "insert into t values (1);"); - yield* fs.writeFileString(path.join(dir, "seeds", "README.md"), "not a seed file"); - const { session, queries } = fakeSession(); - const out = mockOutput(); - yield* run(session, dir, ["seeds"], out); - const upserts = queries.filter((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); - expect(out.rawChunks.map((c) => c.text)).toEqual([ - "Seeding data from seeds/a.sql...\n", - "Seeding data from seeds/b.sql...\n", - ]); - }), + it.effect("expands a matched directory to its sorted, regular .sql files", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.makeDirectory(path.join(dir, "seeds")); + yield* fs.writeFileString(path.join(dir, "seeds", "b.sql"), "insert into t values (2);"); + yield* fs.writeFileString(path.join(dir, "seeds", "a.sql"), "insert into t values (1);"); + yield* fs.writeFileString(path.join(dir, "seeds", "README.md"), "not a seed file"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + yield* run(session, dir, ["seeds"], out); + const upserts = queries.filter((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); + expect(out.rawChunks.map((c) => c.text)).toEqual([ + "Seeding data from seeds/a.sql...\n", + "Seeding data from seeds/b.sql...\n", + ]); + }), ); }); layer(BunServices.layer)("applySeedFiles scanner buffer size", (it) => { it.effect( - "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempDir("seed-scanner-"); @@ -116,7 +114,7 @@ layer(BunServices.layer)("applySeedFiles scanner buffer size", (it) => { const error = yield* run(session, dir, ["big.sql"], mockOutput()).pipe(Effect.flip); expect(error).toBeInstanceOf(MigrationSeedError); expect(error.message).toBe( - "bufio.Scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", + "scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", ); expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), diff --git a/apps/cli/src/command-internal/service-version-overrides.ts b/apps/cli/src/command-internal/service-version-overrides.ts index 19ba71b4d9..20496b3f3e 100644 --- a/apps/cli/src/command-internal/service-version-overrides.ts +++ b/apps/cli/src/command-internal/service-version-overrides.ts @@ -8,7 +8,10 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import { legacyProjectStatePaths, type LegacyStateFile } from "./legacy-project-state.paths.ts"; +import { + legacyProjectStatePaths, + type LegacyStateFile, +} from "../shared/config/legacy-project-state.paths.ts"; import { usesStackState } from "./stack-backend.ts"; /** A saved `.temp` pin is not a usable image tag. The file is left unchanged. */ diff --git a/apps/cli/src/command-internal/size-units.ts b/apps/cli/src/command-internal/size-units.ts index e8af1e3feb..17317229b4 100644 --- a/apps/cli/src/command-internal/size-units.ts +++ b/apps/cli/src/command-internal/size-units.ts @@ -1,9 +1,7 @@ /** - * Parses `file_size_limit` config values using `docker/go-units`' `RAMInBytes` grammar - * (1024-based, case-insensitive, optional trailing `b`) before sending them to service APIs. + * Parses `file_size_limit` config values (1024-based, case-insensitive, optional trailing `b`) + * before sending them to service APIs. * Shared by `config push` (storage/auth/api/db diffing) and `seed buckets`. - * - * @see github.com/docker/go-units@v0.5.0/size.go */ const BINARY_MAP: Readonly> = { @@ -48,7 +46,7 @@ export function ramInBytes(sizeStr: string): number { throw new Error(`invalid size: '${sizeStr}'`); } // Strip the (already-validated, between-digits) underscores before parsing: - // JS `Number.parseFloat("1_000")` stops at the underscore (→1), unlike Go. + // JS `Number.parseFloat("1_000")` stops at the underscore (→1). const size = Number.parseFloat(num.replace(/_/g, "")); // Reject NaN and ±Infinity: an overflowing numeral like `1e309` parses to Infinity in JS, // but must fail here rather than flow through as `null` in the request body. diff --git a/apps/cli/src/command-internal/smtp-enabled.integration.test.ts b/apps/cli/src/command-internal/smtp-enabled.integration.test.ts new file mode 100644 index 0000000000..de2d1710dc --- /dev/null +++ b/apps/cli/src/command-internal/smtp-enabled.integration.test.ts @@ -0,0 +1,50 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { definedEnv } from "../../tests/helpers/config-env-pins.ts"; +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { createStackConfigProject } from "../../tests/helpers/stack-config.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; + +const smtpEnabled = (config: string, shell: Record = {}) => + Effect.gen(function* () { + const root = yield* createStackConfigProject(config, { prefix: "supabase-smtp-enabled-" }); + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(configValuesLayer())); + return resolveSmtpEnabled(resolvedConfig); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withConfigEnv(definedEnv(shell), effect), + Effect.scoped, + ); + +describe("resolveSmtpEnabled", () => { + it.live("is off when [auth.email.smtp] is absent", () => + Effect.gen(function* () { + expect(yield* smtpEnabled('project_id = "p"\n')).toBe(false); + }), + ); + + it.live("is on when the table is present and leaves enabled out", () => + Effect.gen(function* () { + expect(yield* smtpEnabled('[auth.email.smtp]\nhost = "smtp.test"\n')).toBe(true); + }), + ); + + it.live("follows an explicit enabled = false", () => + Effect.gen(function* () { + expect(yield* smtpEnabled("[auth.email.smtp]\nenabled = false\n")).toBe(false); + }), + ); + + it.live("follows an env override of enabled over a present table", () => + Effect.gen(function* () { + const config = "[auth.email.smtp]\nenabled = true\n"; + expect(yield* smtpEnabled(config, { SUPABASE_AUTH_EMAIL_SMTP_ENABLED: "false" })).toBe(false); + }), + ); +}); diff --git a/apps/cli/src/command-internal/smtp-enabled.ts b/apps/cli/src/command-internal/smtp-enabled.ts new file mode 100644 index 0000000000..4ab93b7f0b --- /dev/null +++ b/apps/cli/src/command-internal/smtp-enabled.ts @@ -0,0 +1,7 @@ +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; + +/** A present `[auth.email.smtp]` table without `enabled` counts as on; the schema default is off. */ +export const resolveSmtpEnabled = (resolvedConfig: ResolvedCliConfig): boolean => + resolvedConfig.materialized.originAt("auth.email.smtp.enabled").tier === "default" + ? resolvedConfig.declares("auth.email.smtp") + : resolvedConfig.materialized.config.auth.email.smtp?.enabled === true; diff --git a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts index 2329ad9858..b8795a1d16 100644 --- a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts +++ b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts @@ -46,7 +46,7 @@ const withMode = (target: string, mode: number, body: Effect.Effect { - it.effect("treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", () => + it.effect("treats an empty pattern as no match, not the workdir itself", () => Effect.gen(function* () { const { dir } = yield* tempWorkdir("sql-glob-empty-"); const result = yield* run([""], dir); @@ -55,44 +55,40 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { }), ); - it.effect( - "does not follow a symlinked .sql file below a matched directory (Go WalkDir parity)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-file-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); - const outsideDir = path.join(dir, "outside"); - yield* fs.makeDirectory(outsideDir); - yield* fs.writeFileString(path.join(outsideDir, "evil.sql"), "select 2;"); - yield* fs.symlink(path.join(outsideDir, "evil.sql"), path.join(schemasDir, "linked.sql")); - const result = yield* run(["schemas"], dir); - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("does not follow a symlinked .sql file below a matched directory", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-file-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); + const outsideDir = path.join(dir, "outside"); + yield* fs.makeDirectory(outsideDir); + yield* fs.writeFileString(path.join(outsideDir, "evil.sql"), "select 2;"); + yield* fs.symlink(path.join(outsideDir, "evil.sql"), path.join(schemasDir, "linked.sql")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); - it.effect( - "does not recurse into a symlinked subdirectory below a matched directory (Go WalkDir parity)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-dir-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); - const outsideSubdir = path.join(dir, "outside-subdir"); - yield* fs.makeDirectory(outsideSubdir); - yield* fs.writeFileString(path.join(outsideSubdir, "nested.sql"), "select 3;"); - yield* fs.symlink(outsideSubdir, path.join(schemasDir, "linked-dir")); - const result = yield* run(["schemas"], dir); - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("does not recurse into a symlinked subdirectory below a matched directory", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-dir-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); + const outsideSubdir = path.join(dir, "outside-subdir"); + yield* fs.makeDirectory(outsideSubdir); + yield* fs.writeFileString(path.join(outsideSubdir, "nested.sql"), "select 3;"); + yield* fs.symlink(outsideSubdir, path.join(schemasDir, "linked-dir")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( - "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file (Go parity)", + "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-stat-fail-"); @@ -113,7 +109,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect( - "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match (Go afero.Glob Lstat parity)", + "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-literal-symlink-"); @@ -132,7 +128,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect( - "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component (Go afero.Glob parity)", + "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component", () => Effect.gen(function* () { // The canary file lives in the workdir (never the real "/"), proving this doesn't @@ -148,7 +144,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect( - "recurses through a root-anchored directory component without falling back to the workdir (Go afero.Glob parity)", + "recurses through a root-anchored directory component without falling back to the workdir", () => Effect.gen(function* () { // The workdir here contains a subdirectory that would match "foo*" only if the @@ -166,7 +162,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect( - "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern (Go filepath.Split parity)", + "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-drive-root-"); @@ -190,7 +186,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect( - "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching (Go filepath.ToSlash parity)", + "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching", () => Effect.gen(function* () { const { fs, dir } = yield* tempWorkdir("sql-glob-win-warn-"); @@ -202,41 +198,39 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { }), ); - it.effect( - "sorts raw backslash-joined Windows matches BEFORE slashing, not after (Go afero.Glob/filepath.ToSlash ordering parity)", - () => - Effect.gen(function* () { - // For `a\x.sql` vs `a0\x.sql`, sorting the raw backslash bytes puts `a0\x.sql` first - // (`\` is `0x5C` > `0`'s `0x30`), while sorting the slashed form would put `a/x.sql` - // first instead — a fully faked filesystem supplies canned results keyed by the exact - // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. - const { fs, path, dir } = yield* tempWorkdir("sql-glob-win-sort-"); - const canaryFile = path.join(dir, "canary.sql"); - yield* fs.writeFileString(canaryFile, "select 1;"); - const fileInfo = yield* fs.stat(canaryFile); - const workdir = "/workdir"; - const winFs: FileSystem.FileSystem = { - ...fs, - readDirectory: (p: string) => { - if (p === workdir) return Effect.succeed(["a", "a0"]); - if (p === "\\workdir\\a" || p === "\\workdir\\a0") return Effect.succeed(["x.sql"]); - return fs.readDirectory(p); - }, - stat: (p: string) => - p === "\\workdir\\a\\x.sql" || p === "\\workdir\\a0\\x.sql" - ? Effect.succeed(fileInfo) - : fs.stat(p), - }; - const result = yield* withWin32Path((win32Path) => - sqlFilesGlob(winFs, win32Path, ["a*/x.sql"], workdir), - ); - expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("sorts raw backslash-joined Windows matches BEFORE slashing, not after", () => + Effect.gen(function* () { + // For `a\x.sql` vs `a0\x.sql`, sorting the raw backslash bytes puts `a0\x.sql` first + // (`\` is `0x5C` > `0`'s `0x30`), while sorting the slashed form would put `a/x.sql` + // first instead — a fully faked filesystem supplies canned results keyed by the exact + // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-win-sort-"); + const canaryFile = path.join(dir, "canary.sql"); + yield* fs.writeFileString(canaryFile, "select 1;"); + const fileInfo = yield* fs.stat(canaryFile); + const workdir = "/workdir"; + const winFs: FileSystem.FileSystem = { + ...fs, + readDirectory: (p: string) => { + if (p === workdir) return Effect.succeed(["a", "a0"]); + if (p === "\\workdir\\a" || p === "\\workdir\\a0") return Effect.succeed(["x.sql"]); + return fs.readDirectory(p); + }, + stat: (p: string) => + p === "\\workdir\\a\\x.sql" || p === "\\workdir\\a0\\x.sql" + ? Effect.succeed(fileInfo) + : fs.stat(p), + }; + const result = yield* withWin32Path((win32Path) => + sqlFilesGlob(winFs, win32Path, ["a*/x.sql"], workdir), + ); + expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( - "normalizes a doubled slash when the matched directory itself has a trailing slash (Go path.Join parity)", + "normalizes a doubled slash when the matched directory itself has a trailing slash", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-trailing-slash-"); @@ -249,69 +243,61 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { }), ); - it.effect( - "drops the './' prefix when the matched directory cleans to '.' (Go path.Join parity)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempWorkdir("sql-glob-dot-root-"); - yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); - const nestedDir = path.join(dir, "nested"); - yield* fs.makeDirectory(nestedDir); - yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); - const result = yield* run(["."], dir); - expect(result.files).toEqual(["a.sql", "nested/b.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("drops the './' prefix when the matched directory cleans to '.'", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-dot-root-"); + yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); + const nestedDir = path.join(dir, "nested"); + yield* fs.makeDirectory(nestedDir); + yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); + const result = yield* run(["."], dir); + expect(result.files).toEqual(["a.sql", "nested/b.sql"]); + expect(result.warnings).toEqual([]); + }), ); - it.effect( - "cleans a '..'-segment matched directory when walking its children (Go path.Join parity)", - () => - Effect.gen(function* () { - const { fs, path, dir } = yield* tempWorkdir("sql-glob-dotdot-segment-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); - const result = yield* run(["nested/../schemas"], dir); - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("cleans a '..'-segment matched directory when walking its children", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-dotdot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["nested/../schemas"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); - it.effect( - "cleans a direct glob match whose directory portion has a '.' segment (Go afero.Glob parity)", - () => - Effect.gen(function* () { - // Distinct from the walked-child cleaning tests above: the glob metacharacter is in - // the final component, so this matches directly via `globOne`, never `walkSqlFiles`. - const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dot-segment-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); - const result = yield* run(["schemas/./*.sql"], dir); - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("cleans a direct glob match whose directory portion has a '.' segment", () => + Effect.gen(function* () { + // Distinct from the walked-child cleaning tests above: the glob metacharacter is in + // the final component, so this matches directly via `globOne`, never `walkSqlFiles`. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["schemas/./*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); - it.effect( - "cleans a direct glob match whose directory portion has a '..' segment (Go afero.Glob parity)", - () => - Effect.gen(function* () { - // Same distinction as above (a direct match via `globOne`), but for an embedded `..` - // segment instead of `.`. - const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dotdot-segment-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); - const result = yield* run(["nested/../schemas/*.sql"], dir); - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("cleans a direct glob match whose directory portion has a '..' segment", () => + Effect.gen(function* () { + // Same distinction as above (a direct match via `globOne`), but for an embedded `..` + // segment instead of `.`. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dotdot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["nested/../schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( - "normalizes a doubled slash for a direct glob match under a trailing-slash directory component (Go afero.Glob parity)", + "normalizes a doubled slash for a direct glob match under a trailing-slash directory component", () => Effect.gen(function* () { // `splitPath` on `"schemas//*.sql"` yields `dir: "schemas/"` (the trailing slash @@ -326,30 +312,28 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { }), ); - it.effect( - "still includes a '.sql' child whose stat fails after it's already listed (Go WalkDir parity)", - () => - Effect.gen(function* () { - // Simulates the race by deleting the file the instant after `readDirectory` returns - // it, via a `FileSystem` layer that removes on the first `stat` call for that path. - const { fs, path, dir } = yield* tempWorkdir("sql-glob-stat-race-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - const racyFile = path.join(schemasDir, "racy.sql"); - yield* fs.writeFileString(racyFile, "select 1;"); - const racyFs: FileSystem.FileSystem = { - ...fs, - stat: (p: string) => - p === racyFile ? fs.remove(racyFile).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), - }; - const result = yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); - expect(result.files).toEqual(["schemas/racy.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("still includes a '.sql' child whose stat fails after it's already listed", () => + Effect.gen(function* () { + // Simulates the race by deleting the file the instant after `readDirectory` returns + // it, via a `FileSystem` layer that removes on the first `stat` call for that path. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-stat-race-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + const racyFile = path.join(schemasDir, "racy.sql"); + yield* fs.writeFileString(racyFile, "select 1;"); + const racyFs: FileSystem.FileSystem = { + ...fs, + stat: (p: string) => + p === racyFile ? fs.remove(racyFile).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), + }; + const result = yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); + expect(result.files).toEqual(["schemas/racy.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( - "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory (Go WalkDir parity)", + "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-dir-race-"); @@ -392,7 +376,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { const isRoot = typeof process.getuid === "function" && process.getuid() === 0; it.effect.skipIf(isRoot)( - "surfaces a directory-read failure during walk as a warning instead of an empty match (Go WalkDir parity)", + "surfaces a directory-read failure during walk as a warning instead of an empty match", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-fail-"); @@ -409,7 +393,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect.skipIf(isRoot)( - "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's (Go WalkDir parity)", + "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's", () => Effect.gen(function* () { const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-fail-nested-"); @@ -446,7 +430,7 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { ); it.effect.skipIf(isRoot)( - "picks the lexically-first failing subdirectory as the fatal error, matching Go's fs.WalkDir sorted-visit order (review CLI-1958)", + "picks the lexically-first failing subdirectory as the fatal error", () => Effect.gen(function* () { // The fake `readDirectory` returns "schemas"'s children in reverse @@ -478,21 +462,19 @@ layer(BunServices.layer)("sqlFilesGlob", (it) => { }), ); - it.effect( - "sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units (Go sort.Strings parity)", - () => - Effect.gen(function* () { - // The emoji (4-byte UTF-8, lead byte 0xF0) sorts after the fullwidth exclamation mark - // (3-byte UTF-8, lead byte 0xEF) in byte order, but before it in UTF-16 code-unit - // order (0xD83D surrogate lead vs 0xFF01) — these two characters expose the difference. - const { fs, path, dir } = yield* tempWorkdir("sql-glob-utf8-sort-"); - const schemasDir = path.join(dir, "schemas"); - yield* fs.makeDirectory(schemasDir); - yield* fs.writeFileString(path.join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 - yield* fs.writeFileString(path.join(schemasDir, "!.sql"), "select 2;"); // ! - const result = yield* run(["schemas/*.sql"], dir); - expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); - expect(result.warnings).toEqual([]); - }), + it.effect("sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units", () => + Effect.gen(function* () { + // The emoji (4-byte UTF-8, lead byte 0xF0) sorts after the fullwidth exclamation mark + // (3-byte UTF-8, lead byte 0xEF) in byte order, but before it in UTF-16 code-unit + // order (0xD83D surrogate lead vs 0xFF01) — these two characters expose the difference. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-utf8-sort-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 + yield* fs.writeFileString(path.join(schemasDir, "!.sql"), "select 2;"); // ! + const result = yield* run(["schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); + expect(result.warnings).toEqual([]); + }), ); }); diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 66c7a71742..361f8f74ab 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -9,7 +9,11 @@ interface State { /** Returns the next state, or `null` to emit a token (statement boundary). */ - next(rune: string, data: string): State | null; + next(char: string, data: string): State | null; + /** `true` while inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly open?: boolean; + /** `true` while inside a `BEGIN ATOMIC` body, where `;` no longer ends a statement. */ + readonly atomic?: boolean; } const BEGIN_ATOMIC = "ATOMIC"; @@ -17,17 +21,17 @@ const END_ATOMIC = "END"; // PostgreSQL's scan.l treats every code point at or above 0x80 as an identifier/dollar-tag // character (`ident_cont`/`dolq_cont`), whatever its Unicode category. -const isIdentifierRune = (rune: string): boolean => { - const codePoint = rune.codePointAt(0); - return codePoint !== undefined && (codePoint >= 0x80 || /[A-Za-z0-9_$]/u.test(rune)); +const isIdentifierChar = (char: string): boolean => { + const codePoint = char.codePointAt(0); + return codePoint !== undefined && (codePoint >= 0x80 || /[A-Za-z0-9_$]/u.test(char)); }; // A code point spans at most two UTF-16 units, so the last one before `offset` lies within // the preceding two. -const hasIdentifierRuneBefore = (data: string, offset: number): boolean => { +const hasIdentifierCharBefore = (data: string, offset: number): boolean => { if (offset <= 0) return false; - const rune = Array.from(data.slice(Math.max(0, offset - 2), offset)).at(-1); - return rune !== undefined && isIdentifierRune(rune); + const char = Array.from(data.slice(Math.max(0, offset - 2), offset)).at(-1); + return char !== undefined && isIdentifierChar(char); }; const asciiUpper = (text: string): string => text.replace(/[a-z]/g, (c) => c.toUpperCase()); @@ -35,13 +39,13 @@ const asciiUpper = (text: string): string => text.replace(/[a-z]/g, (c) => c.toU function endsWithKeyword(data: string, keyword: string): boolean { const offset = data.length - keyword.length; if (offset < 0 || asciiUpper(data.slice(offset)) !== keyword) return false; - return !hasIdentifierRuneBefore(data, offset); + return !hasIdentifierCharBefore(data, offset); } -const isSqlWhitespace = (rune: string): boolean => " \t\n\r\f\v".includes(rune); +const isSqlWhitespace = (char: string): boolean => " \t\n\r\f\v".includes(char); // scan.l `newline`: a `--` comment ends at either. -const isNewline = (rune: string): boolean => rune === "\n" || rune === "\r"; +const isNewline = (char: string): boolean => char === "\n" || char === "\r"; function isBeginAtomic(data: string): boolean { if (!endsWithKeyword(data, BEGIN_ATOMIC)) return false; @@ -50,50 +54,72 @@ function isBeginAtomic(data: string): boolean { return endsWithKeyword(data.slice(0, end), "BEGIN"); } -function isCommentsAndWhitespace(text: string): boolean { +// A character that ends a delimiter can't start the next one: `/*/` opens a comment without +// closing it, and `*/*` closes one before a lone `*`. +const blockDelimiterDelta = (window: string): -1 | 0 | 1 => + window === "/*" ? 1 : window === "*/" ? -1 : 0; + +// Index of the first character past leading blanks and comments, or `text.length` when only +// trivia (or an unterminated comment) remains. +function skipLeadingTrivia(text: string, isBlank: (char: string) => boolean): number { let i = 0; while (i < text.length) { - if (isSqlWhitespace(text[i]!)) { + if (isBlank(text[i]!)) { i += 1; } else if (text.startsWith("--", i)) { const newline = text.slice(i + 2).search(/[\n\r]/u); - if (newline === -1) return true; + if (newline === -1) return text.length; i += newline + 3; } else if (text.startsWith("/*", i)) { - // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. let depth = 1; + let afterDelimiter = true; i += 2; while (i < text.length && depth > 0) { - const window = text.slice(i - 1, i + 1); - if (window === "/*") depth += 1; - else if (window === "*/") depth -= 1; + const delta: number = afterDelimiter ? 0 : blockDelimiterDelta(text.slice(i - 1, i + 1)); + afterDelimiter = delta !== 0; + depth += delta; i += 1; } - if (depth > 0) return true; + if (depth > 0) return text.length; } else { - return false; + return i; } } - return true; + return text.length; } +const isCommentsAndWhitespace = (text: string): boolean => + skipLeadingTrivia(text, isSqlWhitespace) === text.length; + +const BYTE_ORDER_MARK = "\uFEFF"; + +/** + * Strips leading whitespace, byte order marks and line/block comments (block comments nest, as + * PostgreSQL scans them) so a keyword check sees the statement's first token. Empty when only + * trivia or an unterminated comment remains. + */ +export const trimLeadingSqlComments = (sql: string): string => + sql + .slice(skipLeadingTrivia(sql, (char) => isSqlWhitespace(char) || char === BYTE_ORDER_MARK)) + .trim(); + class ReadyState implements State { - next(rune: string, data: string): State | null { - switch (rune) { + next(char: string, data: string): State | null { + switch (char) { case "$": { - // A `$` after an identifier rune continues the identifier (`pending$$foo$`), not a + // A `$` after an identifier char continues the identifier (`pending$$foo$`), not a // dollar quote. A digit counts too (`1$$`), unlike PostgreSQL; valid SQL never has that. - const offset = data.length - rune.length; - if (hasIdentifierRuneBefore(data, offset)) return this; + const offset = data.length - char.length; + if (hasIdentifierCharBefore(data, offset)) return this; return new TagState(offset); } case "'": // `E'…'` is an escape string constant only when the `E` starts a token (scan.l // `xestart`); in `type'…'` it ends an identifier. A digit or `$` before the `E` // counts as one too, unlike PostgreSQL; valid SQL never has that. - return new QuoteState(rune, endsWithKeyword(data.slice(0, -1), "E")); + return new QuoteState(char, endsWithKeyword(data.slice(0, -1), "E")); case '"': - return new QuoteState(rune, false); + return new QuoteState(char, false); case "-": return new CommentState(); case "/": @@ -115,32 +141,30 @@ class ReadyState implements State { } class CommentState implements State { - next(rune: string, data: string): State | null { - if (rune === "-") return new LineCommentState(); - return new ReadyState().next(rune, data); + next(char: string, data: string): State | null { + if (char === "-") return new LineCommentState(); + return new ReadyState().next(char, data); } } class LineCommentState implements State { - next(rune: string): State { - return isNewline(rune) ? new ReadyState() : this; + next(char: string): State { + return isNewline(char) ? new ReadyState() : this; } } class BlockState implements State { private depth = 0; - next(rune: string, data: string): State | null { - const window = data.slice(-2); - if (window === "/*") { - this.depth += 1; - return this; - } - if (this.depth === 0) return new ReadyState().next(rune, data); - if (window === "*/") { - this.depth -= 1; - if (this.depth === 0) return new ReadyState(); - } - return this; + private afterDelimiter = false; + get open(): boolean { + return this.depth > 0; + } + next(char: string, data: string): State | null { + const delta = this.afterDelimiter ? 0 : blockDelimiterDelta(data.slice(-2)); + this.afterDelimiter = delta !== 0; + if (this.depth === 0 && delta === 0) return new ReadyState().next(char, data); + this.depth += delta; + return this.depth === 0 ? new ReadyState() : this; } } @@ -151,26 +175,29 @@ class QuoteState implements State { private readonly delimiter: string, private readonly backslashEscapes: boolean, ) {} - next(rune: string, data: string): State | null { + get open(): boolean { + return !this.escape; + } + next(char: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). - if (rune === this.delimiter) { + if (char === this.delimiter) { this.escape = false; return this; } - if (this.backslashEscapes) return new QuoteContinueState().next(rune, data); - return new ReadyState().next(rune, data); + if (this.backslashEscapes) return new QuoteContinueState().next(char, data); + return new ReadyState().next(char, data); } if (this.backslash) { - // Preserve the rune after a backslash (\' or \\). + // Preserve the char after a backslash (\' or \\). this.backslash = false; return this; } - if (this.backslashEscapes && rune === "\\") { + if (this.backslashEscapes && char === "\\") { this.backslash = true; return this; } - if (rune === this.delimiter) this.escape = true; + if (char === this.delimiter) this.escape = true; return this; } } @@ -180,32 +207,33 @@ class QuoteState implements State { class QuoteContinueState implements State { private newline = false; private dashes = 0; - next(rune: string, data: string): State | null { + next(char: string, data: string): State | null { if (this.dashes === 2) { - if (isNewline(rune)) { + if (isNewline(char)) { this.dashes = 0; this.newline = true; } return this; } - if (rune === "-") { + if (char === "-") { this.dashes += 1; return this; } if (this.dashes === 0) { - if (isSqlWhitespace(rune)) { - this.newline ||= isNewline(rune); + if (isSqlWhitespace(char)) { + this.newline ||= isNewline(char); return this; } - if (this.newline && rune === "'") return new QuoteState(rune, true); + if (this.newline && char === "'") return new QuoteState(char, true); } - return new ReadyState().next(rune, data); + return new ReadyState().next(char, data); } } class DollarState implements State { + readonly open = true; constructor(private readonly delimiter: string) {} - next(_rune: string, data: string): State | null { + next(_char: string, data: string): State | null { if (data.slice(-this.delimiter.length) === this.delimiter) return new ReadyState(); return this; } @@ -213,10 +241,10 @@ class DollarState implements State { class TagState implements State { constructor(private readonly offset: number) {} - next(rune: string, data: string): State | null { - if (rune === "$") return new DollarState(data.slice(this.offset)); - if (isIdentifierRune(rune)) return this; - return new ReadyState().next(rune, data); + next(char: string, data: string): State | null { + if (char === "$") return new DollarState(data.slice(this.offset)); + if (isIdentifierChar(char)) return this; + return new ReadyState().next(char, data); } } @@ -228,19 +256,26 @@ class EscapeState implements State { class ParenState implements State { constructor(private prev: State) {} - next(rune: string, data: string): State | null { - const curr = this.prev.next(rune, data); + get open(): boolean { + return this.prev.open === true; + } + get atomic(): boolean { + return this.prev.atomic === true; + } + next(char: string, data: string): State | null { + const curr = this.prev.next(char, data); if (curr === null) { this.prev = new ReadyState(); return this; } this.prev = curr; if (!(this.prev instanceof ReadyState)) return this; - return rune === ")" ? new ReadyState() : this; + return char === ")" ? new ReadyState() : this; } } class AtomicState implements State { + readonly atomic = true; private pendingEnd = false; private statementStart: number; private statementHasContent = false; @@ -250,12 +285,15 @@ class AtomicState implements State { ) { this.statementStart = start; } - next(rune: string, data: string): State | null { + get open(): boolean { + return this.prev.open === true; + } + next(char: string, data: string): State | null { const pendingEnd = this.pendingEnd; this.pendingEnd = false; - if (pendingEnd && !isIdentifierRune(rune)) return new ReadyState().next(rune, data); + if (pendingEnd && !isIdentifierChar(char)) return new ReadyState().next(char, data); // An `END` inside a nested quote/comment doesn't count. - const curr = this.prev.next(rune, data); + const curr = this.prev.next(char, data); if (curr === null) { this.prev = new ReadyState(); this.statementStart = data.length; @@ -289,30 +327,40 @@ interface RawToken { readonly terminated: boolean; } +interface RawSplit { + readonly tokens: RawToken[]; + /** The input ends inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly unterminated: boolean; + /** The input holds a `BEGIN ATOMIC` body, which the splitter reads as part of one statement. */ + readonly beginAtomic: boolean; +} + /** The FSM traversal shared by every `splitSql*` entry point below. */ -function splitRaw(sql: string): RawToken[] { +function splitRaw(sql: string): RawSplit { let state: State = new ReadyState(); + let beginAtomic = false; const tokens: RawToken[] = []; // Slice each token from `sql` instead of growing it with `+=`: states read the token's tail every - // rune, which would rebuild the whole string each time and go quadratic on large tokens. `data` + // char, which would rebuild the whole string each time and go quadratic on large tokens. `data` // starts at the token, so offsets held by states are token-relative. let start = 0; let end = 0; - for (const rune of sql) { - end += rune.length; + for (const char of sql) { + end += char.length; const data = sql.slice(start, end); - const next = state.next(rune, data); + const next = state.next(char, data); if (next === null) { tokens.push({ text: data, terminated: true }); start = end; state = new ReadyState(); } else { state = next; + beginAtomic ||= state.atomic === true; } } // Trailing non-terminated statement at EOF. if (end > start) tokens.push({ text: sql.slice(start), terminated: false }); - return tokens; + return { tokens, unterminated: state.open === true, beginAtomic }; } /** @@ -324,7 +372,7 @@ export function splitSql( ...transform: ReadonlyArray<(s: string) => string> ): string[] { const statements: string[] = []; - for (const { text: raw } of splitRaw(sql)) { + for (const { text: raw } of splitRaw(sql).tokens) { let token = raw; for (const apply of transform) token = apply(token); if (token.length > 0) statements.push(token); @@ -340,6 +388,20 @@ export function splitAndTrim(sql: string): string[] { return splitSql(sql, trimStatement); } +/** + * `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote, and + * whether the splitter read a `BEGIN ATOMIC` body, whose inner statements `statements` omits. + */ +export function splitAndTrimChecked(sql: string): { + readonly statements: string[]; + readonly unterminated: boolean; + readonly beginAtomic: boolean; +} { + const { tokens, unterminated, beginAtomic } = splitRaw(sql); + const statements = tokens.map(({ text }) => trimStatement(text)).filter((s) => s.length > 0); + return { statements, unterminated, beginAtomic }; +} + /** One statement, paired with both its raw and trimmed forms. */ export interface SplitSqlToken { /** The exact text `splitSql(sql)` (no transforms) would emit for this statement. */ @@ -364,7 +426,7 @@ export interface SplitSqlToken { * empty, so callers counting only non-empty trimmed statements still see every raw token. */ export function splitSqlTokens(sql: string): ReadonlyArray { - return splitRaw(sql).map(({ text: raw, terminated }) => ({ + return splitRaw(sql).tokens.map(({ text: raw, terminated }) => ({ raw, trimmed: trimStatement(raw), terminated, diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index b904d19e85..913748fec6 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { findDropStatements, splitAndTrim, splitSql, splitSqlTokens } from "./sql-split.ts"; +import { + findDropStatements, + splitAndTrim, + splitAndTrimChecked, + splitSql, + splitSqlTokens, + trimLeadingSqlComments, +} from "./sql-split.ts"; describe("splitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -142,6 +149,35 @@ describe("splitAndTrim", () => { ]); }); + it.each([ + ["a closing */ directly followed by *", "/* /* */* x */ COMMIT; DELETE FROM t; SELECT 1"], + ["a closing */ directly followed by /*", "/* /* *//* */ */ COMMIT; DELETE FROM t; SELECT 1"], + ["a doubled star before the closing slash", "/* /* **/ */ COMMIT; DELETE FROM t; SELECT 1"], + ])("closes a nested block comment with %s", (_name, sql) => { + const statements = splitAndTrim(sql); + expect(statements).toHaveLength(3); + expect(statements.slice(1)).toEqual(["DELETE FROM t", "SELECT 1"]); + expect(statements[0]!.endsWith("COMMIT")).toBe(true); + }); + + it("does not let /*/ close the comment it opens", () => { + expect(splitAndTrim("SELECT 1 /*/ ; */ ; SELECT 2")).toEqual(["SELECT 1 /*/ ; */", "SELECT 2"]); + }); + + it("reads /*/ inside a comment as a nested opener only", () => { + expect(splitAndTrim("/* a /*/ b */ ; */ SELECT 1; SELECT 2")).toEqual([ + "/* a /*/ b */ ; */ SELECT 1", + "SELECT 2", + ]); + }); + + it("closes a comment written as /**/ and /***/", () => { + expect(splitAndTrim("SELECT /**/ 1; SELECT /***/ 2")).toEqual([ + "SELECT /**/ 1", + "SELECT /***/ 2", + ]); + }); + it("does not split inside a BEGIN ATOMIC body", () => { const sql = "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; SELECT 2; END; SELECT 3;"; @@ -234,7 +270,7 @@ describe("splitAndTrim", () => { it.each(["\u00A0", "\uFEFF", "\u0085"])( "does not treat BEGIN %s ATOMIC as the keyword pair", (gap) => { - // Only PostgreSQL's own whitespace separates the keywords; these are identifier runes. + // Only PostgreSQL's own whitespace separates the keywords; these are identifier characters. const sql = `BEGIN ${gap} ATOMIC; SELECT 1; end; SELECT 2;`; expect(splitAndTrim(sql)).toEqual([`BEGIN ${gap} ATOMIC`, "SELECT 1", "end", "SELECT 2"]); }, @@ -247,9 +283,9 @@ describe("splitAndTrim", () => { ]); }); - it("does not close a BEGIN ATOMIC body at an END after an overlapping block comment", () => { + it("does not close a BEGIN ATOMIC body at an END after a block comment nesting /*/", () => { const body = - "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ SELECT 2 END; SELECT 3; END"; + "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ */ SELECT 2 END; SELECT 3; END"; expect(splitAndTrim(`${body}; SELECT 4;`)).toEqual([body, "SELECT 4"]); }); @@ -262,6 +298,68 @@ describe("splitAndTrim", () => { }); }); +describe("splitAndTrimChecked", () => { + it("returns the statements splitAndTrim returns", () => { + const sql = "SELECT 1; /* a */ SELECT 'b;';\nSELECT $$c; d$$"; + expect(splitAndTrimChecked(sql).statements).toEqual(splitAndTrim(sql)); + }); + + it.each([ + ["an open block comment", "SELECT 1 /* a"], + ["an open nested block comment", "SELECT 1 /* a /* b */"], + ["an open string", "SELECT 'a"], + ["a string ending in a doubled quote", "SELECT 'a''"], + ["an open quoted identifier", 'SELECT "a'], + ["an open escape string", String.raw`SELECT E'a\'`], + ["an open dollar quote", "SELECT $$a"], + ["an open tagged dollar quote", "SELECT $t$a$$"], + ["an open string inside parentheses", "SELECT ('a"], + ["an open comment after a statement", "SELECT 1; /*"], + ])("flags %s as unterminated", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(true); + }); + + it.each([ + ["a plain statement", "SELECT 1"], + ["a trailing line comment", "SELECT 1 -- a"], + ["a closed block comment", "SELECT 1 /* a */"], + ["a closed nested block comment", "SELECT 1 /* a /* b */ */"], + ["a closed string", "SELECT 'a'"], + ["a string with a doubled quote", "SELECT 'a''b'"], + ["a closed quoted identifier", 'SELECT "a"'], + ["a closed escape string", String.raw`SELECT E'a\''`], + ["a closed dollar quote", "SELECT $$a;b$$"], + ["a closed string inside parentheses", "SELECT ('a')"], + ["blank input", " \n"], + ])("does not flag %s", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(false); + }); + + it.each([ + ["a function body", "CREATE FUNCTION f() BEGIN ATOMIC SELECT 1; END"], + ["a lowercase pair split by a newline", "SELECT 1 FROM begin\n atomic; COMMIT; END"], + ["a pair inside parentheses", "SELECT (SELECT 1 FROM begin atomic; COMMIT; END)"], + ["a pair next to a string holding the same words", "SELECT 'begin atomic' FROM begin atomic"], + ])("reports %s as a BEGIN ATOMIC body", (_name, sql) => { + expect(splitAndTrimChecked(sql).beginAtomic).toBe(true); + }); + + it.each([ + ["a string", "SELECT 'begin atomic'"], + ["a string with a newline between the words", "SELECT 'begin\natomic'"], + ["an escape string", String.raw`SELECT E'it\'s begin atomic'`], + ["a Unicode escape string", "SELECT U&'begin atomic'"], + ["a dollar quote", "SELECT $$begin atomic$$"], + ["a quoted identifier", 'SELECT 1 AS "begin atomic"'], + ["a line comment", "SELECT 1 -- begin atomic\n; SELECT 2"], + ["a block comment", "SELECT 1 /* begin atomic */"], + ["an identifier that merely ends in begin", "SELECT 1 xbegin atomic; SELECT 2"], + ["a plain statement", "SELECT 1"], + ])("does not report %s as a BEGIN ATOMIC body", (_name, sql) => { + expect(splitAndTrimChecked(sql).beginAtomic).toBe(false); + }); +}); + describe("splitSql", () => { it("preserves raw statements (no transforms) including the trailing ;-less token", () => { expect(splitSql("SELECT 1; SELECT 2")).toEqual(["SELECT 1;", " SELECT 2"]); @@ -280,3 +378,34 @@ describe("findDropStatements", () => { expect(findDropStatements(sql)).toEqual(["DROP TABLE real"]); }); }); + +describe("trimLeadingSqlComments", () => { + it.each([ + ["whitespace", " \t\n\r\f\vSELECT 1"], + ["a byte order mark", "\uFEFF SELECT 1"], + ["a line comment", "-- a\nSELECT 1"], + ["a carriage-return line comment", "-- a\rSELECT 1"], + ["a block comment", "/* a */SELECT 1"], + ["a nested block comment", "/* a /* b */ c */ SELECT 1"], + ["a nested comment closed by */ then *", "/* /* */* x */ SELECT 1"], + ["a nested comment closed by */ then /*", "/* /* *//* */ */ SELECT 1"], + ["a comment nesting /*/", "/* a /*/ b */ */ SELECT 1"], + ["stacked comments and blanks", "\uFEFF-- a\n /* b */ \n-- c\nSELECT 1"], + ])("strips %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe("SELECT 1"); + }); + + it("keeps comments that are not at the start", () => { + expect(trimLeadingSqlComments("SELECT 1 -- a\n")).toBe("SELECT 1 -- a"); + }); + + it.each([ + ["a line comment without a newline", "-- a"], + ["an unterminated block comment", "/* a SELECT 1"], + ["a comment opened by /*/ and closed later", "/*/ SELECT 1 */"], + ["a nested comment closed only once", "/* /* */* x SELECT 1"], + ["only whitespace", " \n"], + ])("returns empty for %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe(""); + }); +}); diff --git a/apps/cli/src/command-internal/stack-auth-config.ts b/apps/cli/src/command-internal/stack-auth-config.ts index d5f818e5d9..524c0644b8 100644 --- a/apps/cli/src/command-internal/stack-auth-config.ts +++ b/apps/cli/src/command-internal/stack-auth-config.ts @@ -1,6 +1,6 @@ import type { CliConfig } from "@supabase/config"; import type { ServiceCreation } from "@supabase/stack/effect"; -import { Effect } from "effect"; +import { Effect, Struct } from "effect"; import type { ResolvedAuthExternalProvider } from "./local-config-values.ts"; import { passwordRequirementsToChar } from "./password-requirements.ts"; @@ -28,6 +28,7 @@ const passThrough = section; interface ResolvedAuthOptions { + readonly smtpEnabled: boolean; readonly authExternalUrl?: string; readonly apiExternalUrl?: string; readonly passkeyEnabled?: boolean; @@ -89,7 +90,7 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( ]), ), }, - sms: passThrough()(auth.sms), + sms: passThrough()(Struct.omit(auth.sms, ["enabled"])), ...(auth.captcha === undefined ? {} : { captcha: passThrough()(auth.captcha) }), @@ -115,7 +116,7 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( web3: passThrough()(auth.web3), oauthServer: passThrough()(auth.oauth_server), }, - ...(auth.email.smtp?.enabled !== true + ...(options.smtpEnabled !== true || auth.email.smtp === undefined ? localSmtp.enabled ? { smtpAdminEmail: localSmtp.admin_email ?? "admin@email.com", diff --git a/apps/cli/src/command-internal/stack-backend.ts b/apps/cli/src/command-internal/stack-backend.ts index bbe6354c8c..850836704b 100644 --- a/apps/cli/src/command-internal/stack-backend.ts +++ b/apps/cli/src/command-internal/stack-backend.ts @@ -9,12 +9,12 @@ import { type Path, } from "effect"; import { extractCommandPath, hasRootVersionFlag } from "../shared/cli/run.ts"; -import type { ExperimentalFeatureFlagError } from "./experimental-feature.ts"; +import type { ExperimentalFeatureFlagError } from "../shared/config/experimental-feature.ts"; import { projectStateMode, type ProjectStateMode, softProjectStateMode, -} from "./project-state-mode.ts"; +} from "../shared/config/project-state-mode.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -89,7 +89,6 @@ export const resolveProjectStateSelection = Effect.fn("ProjectState.resolveSelec function* (input: { readonly args: ReadonlyArray; readonly cwd: string; - readonly env: Readonly>; }): Effect.fn.Return< { readonly projectState: Exit.Exit | undefined; diff --git a/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts b/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts new file mode 100644 index 0000000000..bdaf084706 --- /dev/null +++ b/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts @@ -0,0 +1,194 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect"; + +import { mockCommandSettings, useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { ConfigEnvPins } from "../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../tests/helpers/config-goldens.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { loadStackConfig } from "./stack-config.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const STACK_ID = "golden-stack"; +const GOLDEN_DIR = "./testdata/config-precedence/stack-config"; + +const BASE_CONFIG = `project_id = "golden-stack" + +[api] +port = 55321 +schemas = ["public", "base_api"] + +[db] +port = 55322 + +[auth] +site_url = "http://base.example.com" +enable_signup = true + +[studio] +port = 55323 + +[realtime] +enabled = true + +[storage] +enabled = true +`; + +const REMOTE_BLOCK = ` +[remotes.prod] +project_id = "${TARGET_REF}" + +[remotes.prod.api] +schemas = ["public", "remote_api"] + +[remotes.prod.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.prod.realtime] +enabled = false +`; + +const SHELL_ENV = { + SUPABASE_API_PORT: "56321", + SUPABASE_DB_PORT: "56322", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_REALTIME_ENABLED: "false", + SUPABASE_STUDIO_ENABLED: "false", +}; + +const DOTENV = [ + "SUPABASE_API_PORT=57321", + "SUPABASE_DB_PORT=57322", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_AUTH_SITE_URL=http://dotenv.example.com", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_REALTIME_ENABLED=false", + "SUPABASE_STUDIO_ENABLED=false", + "", +].join("\n"); + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; + readonly contextRef?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "no-overrides", + name: "stack config with base config and no env resolves the base values (pre-refactor)", + config: BASE_CONFIG, + }, + { + golden: "remote-block-ignored", + name: "stack config ignores a [remotes.*] block because no caller passes a ref (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + }, + { + golden: "remote-block-merged-via-context", + name: "stack config applies a remote block only when the caller preloads a ref-scoped context (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + contextRef: TARGET_REF, + }, + { + golden: "remote-block-merged-via-context-with-shell-env", + name: "stack config lets shell env beat a merged remote block for the same keys (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: SHELL_ENV, + contextRef: TARGET_REF, + }, + { + golden: "shell-env", + name: "stack config shell env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + shellEnv: SHELL_ENV, + }, + { + golden: "project-dotenv", + name: "stack config supabase/.env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + dotenv: DOTENV, + }, + { + golden: "shell-beats-dotenv", + name: "stack config shell env beats supabase/.env for the same keys (pre-refactor)", + config: BASE_CONFIG, + shellEnv: { + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + }, + dotenv: DOTENV, + }, +]; + +const configEnvOnly = (env: Readonly>) => + Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SUPABASE_"))); + +const tempRoot = useTempWorkdir("stack-config-golden-"); + +describe("stack config precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.live(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(fixture.shellEnv ?? {}); + + const layer = Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + mockCommandSettings({ workdir, supabaseHome: workdir }), + cliConfigValuesTestLayer, + Layer.succeed(ConfigEnvPins, fixture.shellEnv ?? {}), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => + value === undefined ? [] : [[name, value]], + ), + ), + { preserveEmptyStrings: true }, + ), + ), + ); + + const config = yield* loadStackConfig( + workdir, + fixture.contextRef === undefined ? undefined : { projectRef: fixture.contextRef }, + ).pipe(Effect.provide(layer)); + + const creations = yield* config.creations(STACK_ID); + const keys = yield* config.keys; + const golden = { + // Pins come from the artifact catalog, not config precedence. + creations: creations.map(({ pin: _pin, ...creation }) => creation), + keys, + source: config.source, + projectEnvValues: configEnvOnly(config.projectEnvValues), + }; + const goldenText = yield* goldenJson(golden, { [workdir]: "" }); + yield* Effect.promise(() => + ctx.expect(goldenText).toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 2a843e74e1..2f89f78f36 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -1,7 +1,5 @@ import { CommandSettings } from "../config/command-settings.service.ts"; import { getDefaultCliConfig, type CliConfig } from "@supabase/config"; -import { resolveCliConfigSubtree } from "@supabase/config/internal"; -import { validateCliConfig } from "@supabase/config/effect"; import { DEFAULT_LOCAL_S3_ACCESS_KEY_ID, DEFAULT_LOCAL_S3_REGION, @@ -10,14 +8,26 @@ import { } from "@supabase/stack/defaults"; import { type ServiceCreationInput as ServiceCreationType } from "@supabase/stack/effect"; import { type ArtifactSelection } from "@supabase/stack/internal/artifacts"; -import { Crypto, Effect, Data, FileSystem, Path, Redacted, Schema, SchemaIssue } from "effect"; +import { Crypto, Effect, Data, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; -import { loadLocalProjectContext, type LocalProjectContext } from "./local-project-context.ts"; +import { CliConfigKeys, type AnyCliConfigKey } from "../config/cli-config-keys.ts"; +import { resolveConfigSubtree } from "../config/cli-config-subtree.ts"; +import type { + CliConfigMaterialized, + CliConfigValues, +} from "../config/cli-config-values.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; +import { decryptSecretMap } from "../shared/config/vault-decrypt.ts"; +import { + describeConfigLoadFailure, + loadResolvedConfigContext, + resolvePasskeyWebauthn, +} from "./resolved-config-context.ts"; import { resolveAuthConfig } from "./stack-auth-config.ts"; -import { parseGoDuration } from "./go-duration.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; +import { parseDuration } from "./duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; import { stackArtifactSelection } from "./stack-artifact-selection.ts"; import { @@ -27,42 +37,11 @@ import { } from "./stack-lock.ts"; import { - decryptAuthSecret, resolveJwtSecret, resolveConfiguredSigningKeys, - envOverride, - envOverrideApiMaxRows, - envOverrideAuthPasswordRequirements, - envOverrideAnalyticsBackend, - envOverrideBool, - envOverrideDefaultPoolSize, - envOverrideDenoVersion, - envOverrideEdgeRuntimePolicy, - envOverrideMaxClientConn, - envOverrideMajorVersion, - envOverrideOrioleDBVersion, - envOverridePort, - envOverridePoolMode, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - envOverrideUint, - resolveAuthCaptcha, - resolveAuthEmail, - resolveAuthEmailSmtp, - resolveAuthExternalUrl, resolveAuthExternalProviders, - resolveAuthHooks, - resolveAuthMfa, - resolveAuthSms, - resolveDbSettingsEnvOverrides, - resolveGotrueOAuthServer, - resolveGotruePasskeyWebauthn, - resolveGotrueRateLimit, - resolveGotrueSessions, - resolveGotrueWeb3, - strToArr, } from "./local-config-values.ts"; -import { generateAsymmetricGoJwt } from "./go-jwt.ts"; +import { generateAsymmetricLocalJwt } from "./local-jwt.ts"; import { recordOrioleDbTelemetry } from "./db-image.ts"; import { resolveRemoteJwks, @@ -93,7 +72,7 @@ interface StackStartConfig { ) => Effect.Effect, StackConfigError>; readonly source: CliConfig; readonly projectEnvValues: Readonly>; - readonly document?: Record; + readonly originAt: CliConfigMaterialized["originAt"]; readonly remoteJwks: Effect.Effect; readonly keys: Effect.Effect< { @@ -114,7 +93,13 @@ interface StackStartConfig { type StackConfigEffect = Effect.Effect< StackStartConfig, StackConfigError | LockResolutionError | E, - FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto | CommandSettings | R + | FileSystem.FileSystem + | Path.Path + | RuntimeInfo + | Crypto.Crypto + | CommandSettings + | CliConfigValues + | R >; /** Host directory holding a stack's Storage objects, beside its runtime state. */ @@ -125,8 +110,8 @@ export const stackUploadsDir = ( ): string => path.join(supabaseHome, "stacks", stackId, "uploads"); interface StackConfigOptions { - /** An already-loaded project context to reuse. */ - readonly context?: LocalProjectContext; + /** The hosted project whose `[remotes.*]` block applies. */ + readonly projectRef?: string; /** Chooses the artifact pins instead of the bundled ones; without a `supabase/config.toml` the bundled pins apply, or a frozen run fails. */ readonly selection?: ( request: LockRequest, @@ -146,694 +131,39 @@ const isRecord = (value: unknown): value is Readonly> => const encodeJwkArray = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Unknown))); -const withoutUndefined = (value: unknown): unknown => { - if (Redacted.isRedacted(value)) return value; - if (Array.isArray(value)) return value.map(withoutUndefined); - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value) - .filter(([, item]) => item !== undefined) - .map(([key, item]) => [key, withoutUndefined(item)]), - ); -}; - const section = (document: Readonly> | undefined, name: string) => { const value = document?.[name]; return isRecord(value) ? value : undefined; }; -/** A setting's `config.toml` key and the `SUPABASE_*` env var that overrides it. */ -export interface StackEndpointSetting { - readonly configPath: string; - readonly envVar: string; - /** A deprecated alias of `envVar`, honored only while `envVar` is unset. */ - readonly deprecatedEnvVar?: string; -} - -// Shared by `createCreations` and `stackEndpointSetting`; a new endpoint also needs an entry in -// `endpointSettingsByServiceEndpoint`. -const DB_PORT: StackEndpointSetting = { - envVar: "SUPABASE_DB_PORT", - configPath: "db.port", -}; -const API_PORT: StackEndpointSetting = { - envVar: "SUPABASE_API_PORT", - configPath: "api.port", -}; -const STUDIO_PORT: StackEndpointSetting = { - envVar: "SUPABASE_STUDIO_PORT", - configPath: "studio.port", -}; -const DB_POOLER_PORT: StackEndpointSetting = { - envVar: "SUPABASE_DB_POOLER_PORT", - configPath: "db.pooler.port", -}; -const LOCAL_SMTP_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_PORT", - configPath: "local_smtp.port", -}; -const LOCAL_SMTP_SMTP_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT", - configPath: "local_smtp.smtp_port", -}; -const LOCAL_SMTP_POP3_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT", - configPath: "local_smtp.pop3_port", -}; -const ANALYTICS_PORT: StackEndpointSetting = { - envVar: "SUPABASE_ANALYTICS_PORT", - configPath: "analytics.port", -}; -const EDGE_RUNTIME_INSPECTOR_PORT: StackEndpointSetting = { - envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - configPath: "edge_runtime.inspector_port", -}; - -/** The configured port when the setting is present in `config.toml` or its env var, else undefined. */ -const resolvePort = ( - setting: StackEndpointSetting, - document: Readonly> | undefined, - configured: number, - env: Readonly>, -): number | undefined => { - if (envOverride(setting.envVar, undefined, env) !== undefined) return configured; - const path = setting.configPath.split("."); - const key = path[path.length - 1] ?? ""; - const parent = path - .slice(0, -1) - .reduce> | undefined>( - (node, name) => section(node, name), - document, - ); - return typeof parent?.[key] === "number" ? configured : undefined; -}; +/** The config key behind a saved stack endpoint; its `path` and `env[0]` name the setting. */ +export type StackEndpointKey = Pick; /** - * Maps a saved stack endpoint (service + endpoint name) to the `StackEndpointSetting` that controls it. + * Maps a saved stack endpoint (service + endpoint name) to the config key that controls it. * An endpoint missing here (e.g. `pooler.http`, `realtime.rpc`) is always automatic. */ -const endpointSettingsByServiceEndpoint: Readonly> = { - "database.sql": DB_PORT, - "pooler.sql": DB_POOLER_PORT, - "analytics.http": ANALYTICS_PORT, - "studio.http": STUDIO_PORT, - "mail.http": LOCAL_SMTP_PORT, - "mail.smtp": LOCAL_SMTP_SMTP_PORT, - "mail.pop3": LOCAL_SMTP_POP3_PORT, - "functions.inspector": EDGE_RUNTIME_INSPECTOR_PORT, - "rest.http": API_PORT, - "auth.http": API_PORT, - "realtime.http": API_PORT, - "storage.http": API_PORT, - "functions.http": API_PORT, +const endpointKeysByServiceEndpoint: Readonly> = { + "database.sql": CliConfigKeys.db.port, + "pooler.sql": CliConfigKeys.db.pooler.port, + "analytics.http": CliConfigKeys.analytics.port, + "studio.http": CliConfigKeys.studio.port, + "mail.http": CliConfigKeys.localSmtp.port, + "mail.smtp": CliConfigKeys.localSmtp.smtpPort, + "mail.pop3": CliConfigKeys.localSmtp.pop3Port, + "functions.inspector": CliConfigKeys.edgeRuntime.inspectorPort, + "rest.http": CliConfigKeys.api.port, + "auth.http": CliConfigKeys.api.port, + "realtime.http": CliConfigKeys.api.port, + "storage.http": CliConfigKeys.api.port, + "functions.http": CliConfigKeys.api.port, }; -/** The config.toml key and env var override for a service endpoint, when the CLI exposes one. */ -export const stackEndpointSetting = ( - service: string, - endpoint: string, -): StackEndpointSetting | undefined => endpointSettingsByServiceEndpoint[`${service}.${endpoint}`]; +/** The config key that sets a service endpoint's port, when the CLI exposes one. */ +export const stackEndpointKey = (service: string, endpoint: string): StackEndpointKey | undefined => + endpointKeysByServiceEndpoint[`${service}.${endpoint}`]; -/** - * `db.major_version`'s config key and `SUPABASE_DB_MAJOR_VERSION` override. Unlike the ports - * above, `envOverrideMajorVersion` (shared with `db-bootstrap` and the legacy local stack) hardcodes - * its own name/field, so there is no single call site to read this from without widening that - * shared helper's signature; the two literals here are kept in sync by hand. - */ -export const stackMajorVersionSetting: StackEndpointSetting = { - configPath: "db.major_version", - envVar: "SUPABASE_DB_MAJOR_VERSION", -}; - -/** `db.orioledb_version`'s config key and `SUPABASE_DB_ORIOLEDB_VERSION` override. */ -export const stackOrioledbVersionSetting: StackEndpointSetting = { - configPath: "db.orioledb_version", - envVar: "SUPABASE_DB_ORIOLEDB_VERSION", - deprecatedEnvVar: "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", -}; - -const authProviderNames = [ - "apple", - "azure", - "bitbucket", - "discord", - "facebook", - "github", - "gitlab", - "google", - "kakao", - "keycloak", - "linkedin_oidc", - "notion", - "twitch", - "twitter", - "x", - "slack_oidc", - "spotify", - "workos", - "zoom", -] as const; - -const resolvedPort = ( - name: string, - configured: number, - field: string, - env: Readonly>, -) => - envOverride(name, undefined, env) === undefined - ? configured - : envOverridePort(name, configured, field, env); - -const resolveAuthOverrides = ( - auth: CliConfig["auth"], - document: Readonly> | undefined, - env: Readonly>, -) => { - const authDocument = section(document, "auth"); - const email = resolveAuthEmail(auth.email, authDocument, env); - const smtp = resolveAuthEmailSmtp(authDocument, env); - const hooks = resolveAuthHooks(authDocument, auth.hook, env); - const external = resolveAuthExternalProviders(authDocument, auth.external, env); - const externalResolved = { ...auth.external }; - for (const name of authProviderNames) { - const value = external[name]; - if (value === undefined) continue; - externalResolved[name] = { - ...externalResolved[name], - enabled: value.enabled, - client_id: value.clientId ?? externalResolved[name].client_id, - secret: value.secret, - url: value.url, - redirect_uri: value.redirectUri ?? externalResolved[name].redirect_uri, - skip_nonce_check: value.skipNonceCheck, - email_optional: value.emailOptional, - }; - } - const figma = external.figma; - if (figma !== undefined) { - externalResolved.figma = { - ...externalResolved.figma, - enabled: figma.enabled, - client_id: figma.clientId ?? externalResolved.figma?.client_id, - secret: figma.secret, - url: figma.url, - redirect_uri: figma.redirectUri ?? externalResolved.figma?.redirect_uri, - skip_nonce_check: figma.skipNonceCheck, - email_optional: figma.emailOptional, - }; - } - const hookValue = ( - value: (typeof hooks)[keyof typeof hooks], - fallback: (typeof auth.hook)[keyof typeof auth.hook], - ) => value ?? fallback; - const hookResolved = { - mfa_verification_attempt: hookValue( - hooks.mfaVerificationAttempt, - auth.hook.mfa_verification_attempt, - ), - password_verification_attempt: hookValue( - hooks.passwordVerificationAttempt, - auth.hook.password_verification_attempt, - ), - custom_access_token: hookValue(hooks.customAccessToken, auth.hook.custom_access_token), - send_sms: hookValue(hooks.sendSms, auth.hook.send_sms), - send_email: hookValue(hooks.sendEmail, auth.hook.send_email), - before_user_created: hookValue(hooks.beforeUserCreated, auth.hook.before_user_created), - }; - const resolvedEmailTemplate: CliConfig["auth"]["email"]["template"] = Object.fromEntries( - Object.entries(email.template).map(([name, value]) => [ - name, - { subject: value.subject ?? "", content_path: value.content_path }, - ]), - ); - const resolvedEmailNotification: CliConfig["auth"]["email"]["notification"] = Object.fromEntries( - Object.entries(email.notification).map(([name, value]) => [ - name, - { - enabled: value.enabled, - subject: value.subject ?? "", - content_path: value.content_path, - }, - ]), - ); - const resolvedEmail = { - ...email, - template: resolvedEmailTemplate, - notification: resolvedEmailNotification, - }; - const additionalRedirectUrls = envOverride( - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - undefined, - env, - ); - const resolvedSmtp = - smtp === undefined - ? undefined - : { - enabled: smtp.enabled, - host: smtp.host, - ...(smtp.port === 0 ? {} : { port: smtp.port }), - user: smtp.user, - pass: smtp.pass, - admin_email: smtp.adminEmail, - sender_name: smtp.senderName, - }; - const resolvedSms = resolveAuthSms(authDocument, auth.sms, env); - return { - ...auth, - enabled: envOverrideBool("SUPABASE_AUTH_ENABLED", auth.enabled, "auth.enabled", env), - site_url: envOverride("SUPABASE_AUTH_SITE_URL", auth.site_url, env), - additional_redirect_urls: - additionalRedirectUrls === undefined - ? auth.additional_redirect_urls - : strToArr(additionalRedirectUrls), - jwt_expiry: envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - auth.jwt_expiry, - env, - ), - enable_refresh_token_rotation: envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - env, - ), - refresh_token_reuse_interval: envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - auth.refresh_token_reuse_interval, - env, - ), - enable_manual_linking: envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - auth.enable_manual_linking, - "auth.enable_manual_linking", - env, - ), - enable_signup: envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - auth.enable_signup, - "auth.enable_signup", - env, - ), - enable_anonymous_sign_ins: envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - env, - ), - minimum_password_length: envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - auth.minimum_password_length, - env, - ), - password_requirements: envOverrideAuthPasswordRequirements(auth.password_requirements, env), - publishable_key: envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", auth.publishable_key, env), - secret_key: envOverride("SUPABASE_AUTH_SECRET_KEY", auth.secret_key, env), - anon_key: envOverride("SUPABASE_AUTH_ANON_KEY", auth.anon_key, env), - service_role_key: envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, env), - rate_limit: resolveGotrueRateLimit(auth.rate_limit, env), - captcha: resolveAuthCaptcha(authDocument, auth.captcha, env), - hook: hookResolved, - mfa: resolveAuthMfa(auth.mfa, env), - sessions: resolveGotrueSessions(auth.sessions, env), - email: { ...resolvedEmail, smtp: resolvedSmtp }, - sms: { - ...resolvedSms, - twilio: { - ...resolvedSms.twilio, - content_sid: envOverride( - "SUPABASE_AUTH_SMS_TWILIO_CONTENT_SID", - auth.sms.twilio.content_sid, - env, - ), - }, - }, - external: externalResolved, - web3: resolveGotrueWeb3(auth.web3, env), - oauth_server: resolveGotrueOAuthServer(auth.oauth_server, env), - }; -}; - -const resolveEffectiveCliConfig = ( - config: CliConfig, - document: Readonly> | undefined, - env: Readonly>, -): CliConfig => { - const api = config.api; - const db = config.db; - const storage = config.storage; - const realtime = config.realtime; - const analytics = config.analytics; - const studio = config.studio; - const mail = config.local_smtp; - const pooler = db.pooler; - const edge = config.edge_runtime; - const experimental = config.experimental; - const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, env); - const apiExtraSearchPathOverride = envOverride("SUPABASE_API_EXTRA_SEARCH_PATH", undefined, env); - const imageDocument = section(section(document, "storage"), "image_transformation"); - const imageEnabledOverride = envOverride( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - undefined, - env, - ); - const imageEnabledExplicit = - imageDocument?.enabled !== undefined || imageEnabledOverride !== undefined; - const resolvedApi = { - ...api, - enabled: envOverrideBool("SUPABASE_API_ENABLED", api.enabled, "api.enabled", env), - port: resolvedPort("SUPABASE_API_PORT", api.port, "api.port", env), - schemas: apiSchemasOverride === undefined ? api.schemas : strToArr(apiSchemasOverride), - extra_search_path: - apiExtraSearchPathOverride === undefined - ? api.extra_search_path - : strToArr(apiExtraSearchPathOverride), - max_rows: envOverrideApiMaxRows(api.max_rows, env), - tls: { - ...api.tls, - enabled: envOverrideBool("SUPABASE_API_TLS_ENABLED", api.tls.enabled, "api.tls.enabled", env), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, env), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, env), - }, - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, env), - auto_expose_new_tables: - api.auto_expose_new_tables !== undefined || - envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", undefined, env) !== undefined - ? envOverrideBool( - "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", - api.auto_expose_new_tables ?? false, - "api.auto_expose_new_tables", - env, - ) - : undefined, - }; - const resolvedStorage = { - ...storage, - enabled: envOverrideBool("SUPABASE_STORAGE_ENABLED", storage.enabled, "storage.enabled", env), - file_size_limit: envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - String(storage.file_size_limit), - env, - ), - image_transformation: imageEnabledExplicit - ? { - ...storage.image_transformation, - enabled: envOverrideBool( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - storage.image_transformation?.enabled ?? false, - "storage.image_transformation.enabled", - env, - ), - } - : undefined, - s3_protocol: { - ...storage.s3_protocol, - enabled: envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - env, - ), - }, - analytics: { - ...storage.analytics, - enabled: envOverrideBool( - "SUPABASE_STORAGE_ANALYTICS_ENABLED", - storage.analytics.enabled, - "storage.analytics.enabled", - env, - ), - max_namespaces: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", - "storage.analytics.max_namespaces", - storage.analytics.max_namespaces, - env, - ), - max_tables: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", - "storage.analytics.max_tables", - storage.analytics.max_tables, - env, - ), - max_catalogs: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", - "storage.analytics.max_catalogs", - storage.analytics.max_catalogs, - env, - ), - }, - vector: { - ...storage.vector, - enabled: envOverrideBool( - "SUPABASE_STORAGE_VECTOR_ENABLED", - storage.vector.enabled, - "storage.vector.enabled", - env, - ), - max_buckets: envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", - "storage.vector.max_buckets", - storage.vector.max_buckets, - env, - ), - max_indexes: envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", - "storage.vector.max_indexes", - storage.vector.max_indexes, - env, - ), - }, - }; - const resolvedEdge = { - ...edge, - enabled: envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - edge.enabled, - "edge_runtime.enabled", - env, - ), - policy: envOverrideEdgeRuntimePolicy(edge.policy, env), - deno_version: envOverrideDenoVersion(edge.deno_version, env), - inspector_port: resolvedPort( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - edge.inspector_port, - "edge_runtime.inspector_port", - env, - ), - }; - const resolvedAnalytics = { - ...analytics, - enabled: envOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - analytics.enabled, - "analytics.enabled", - env, - ), - port: resolvedPort("SUPABASE_ANALYTICS_PORT", analytics.port, "analytics.port", env), - backend: envOverrideAnalyticsBackend(analytics.backend, env), - gcp_project_id: envOverride("SUPABASE_ANALYTICS_GCP_PROJECT_ID", analytics.gcp_project_id, env), - gcp_project_number: envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - analytics.gcp_project_number, - env, - ), - gcp_jwt_path: envOverride("SUPABASE_ANALYTICS_GCP_JWT_PATH", analytics.gcp_jwt_path, env), - }; - const resolvedPooler = { - ...pooler, - enabled: envOverrideBool( - "SUPABASE_DB_POOLER_ENABLED", - pooler.enabled, - "db.pooler.enabled", - env, - ), - port: resolvedPort("SUPABASE_DB_POOLER_PORT", pooler.port, "db.pooler.port", env), - pool_mode: envOverridePoolMode(pooler.pool_mode, env), - default_pool_size: envOverrideDefaultPoolSize(pooler.default_pool_size, env), - max_client_conn: envOverrideMaxClientConn(pooler.max_client_conn, env), - }; - const authEnabled = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - env, - ); - const thirdParty = { - firebase: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - env, - ), - project_id: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - env, - ), - }, - auth0: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - env, - ), - tenant: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - env, - ), - tenant_region: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - env, - ), - }, - aws_cognito: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - env, - ), - user_pool_id: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - env, - ), - user_pool_region: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - env, - ), - }, - clerk: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - env, - ), - domain: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - env, - ), - }, - workos: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - env, - ), - issuer_url: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - env, - ), - }, - }; - // JWT security settings apply to non-Auth workloads too, so resolve them - // regardless of whether the Auth capability is enabled. - const authResolved = { - ...(authEnabled ? resolveAuthOverrides(config.auth, document, env) : config.auth), - enabled: authEnabled, - third_party: thirdParty, - jwt_issuer: envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, env), - signing_keys_path: envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - env, - ), - jwt_secret: decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, env), - env, - ), - }; - return { - ...config, - api: resolvedApi, - auth: authResolved, - db: { - ...db, - port: resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), - major_version: envOverrideMajorVersion(db.major_version, env), - health_timeout: envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), - orioledb_version: envOverrideOrioleDBVersion(db.orioledb_version, env), - settings: resolveDbSettingsEnvOverrides(db.settings, env), - pooler: resolvedPooler, - }, - edge_runtime: resolvedEdge, - // Same S3 overrides legacy Compose applies, so the OrioleDB S3 guard sees env-only values. - experimental: { - ...experimental, - s3_host: envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", experimental.s3_host, env), - s3_region: envOverride("SUPABASE_EXPERIMENTAL_S3_REGION", experimental.s3_region, env), - s3_access_key: envOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - experimental.s3_access_key, - env, - ), - s3_secret_key: envOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - experimental.s3_secret_key, - env, - ), - }, - realtime: { - ...realtime, - enabled: envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - realtime.enabled, - "realtime.enabled", - env, - ), - ip_version: envOverrideRealtimeIpVersion(realtime.ip_version, env), - max_header_length: envOverrideRealtimeMaxHeaderLength(realtime.max_header_length, env), - }, - storage: resolvedStorage, - analytics: resolvedAnalytics, - studio: { - ...studio, - enabled: envOverrideBool("SUPABASE_STUDIO_ENABLED", studio.enabled, "studio.enabled", env), - port: resolvedPort("SUPABASE_STUDIO_PORT", studio.port, "studio.port", env), - api_url: envOverride("SUPABASE_STUDIO_API_URL", studio.api_url, env), - openai_api_key: decryptAuthSecret( - envOverride("SUPABASE_STUDIO_OPENAI_API_KEY", studio.openai_api_key, env), - env, - ), - }, - local_smtp: { - ...mail, - enabled: envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - mail.enabled, - "local_smtp.enabled", - env, - ), - port: resolvedPort("SUPABASE_LOCAL_SMTP_PORT", mail.port, "local_smtp.port", env), - smtp_port: resolvedPort( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", - mail.smtp_port ?? 0, - "local_smtp.smtp_port", - env, - ), - pop3_port: resolvedPort( - "SUPABASE_LOCAL_SMTP_POP3_PORT", - mail.pop3_port ?? 0, - "local_smtp.pop3_port", - env, - ), - admin_email: envOverride("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", mail.admin_email, env), - sender_name: envOverride("SUPABASE_LOCAL_SMTP_SENDER_NAME", mail.sender_name, env), - }, - }; -}; +export const stackMajorVersionKey: StackEndpointKey = CliConfigKeys.db.majorVersion; const unsupportedConfigPaths = [ { path: "api.tls", active: (config: CliConfig) => config.api.enabled }, @@ -930,6 +260,9 @@ const configValidationError = (config: CliConfig): string | undefined => { return undefined; }; +const toStackConfigError = (cause: { readonly message: string }) => + new StackConfigError({ message: cause.message }); + const endpoint = (port: number | undefined): { readonly port: number | "auto" } => ({ port: port === undefined ? "auto" : port, }); @@ -941,38 +274,20 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( opts?: StackConfigOptions, ): StackConfigEffect => Effect.gen(function* () { - const context = - opts?.context ?? - (yield* loadLocalProjectContext( - projectRoot, - (message) => new StackConfigError({ message }), - )); - const effectiveInput = yield* Effect.try({ - try: () => - resolveEffectiveCliConfig( - context.config, - context.loaded?.document, - context.projectEnvValues, - ), - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : "invalid config overrides", - }), - }); - const validatedConfig = yield* validateCliConfig(withoutUndefined(effectiveInput)).pipe( - Effect.mapError((cause) => { - const issues = SchemaIssue.makeFormatterStandardSchemaV1({ - leafHook: () => "Invalid value", - checkHook: () => undefined, - })(cause.issue).issues; - const path = issues[0]?.path - ?.map((segment) => String(typeof segment === "object" ? segment.key : segment)) - .join("."); - return new StackConfigError({ - message: path === undefined ? "invalid config" : `invalid config at ${path}`, - }); - }), + const { + resolvedConfig, + config: validatedConfig, + projectEnvValues, + document, + } = yield* loadResolvedConfigContext( + projectRoot, + Option.fromNullishOr(opts?.projectRef), + ).pipe( + Effect.mapError( + (cause) => new StackConfigError({ message: describeConfigLoadFailure(cause) }), + ), ); + const { originAt } = resolvedConfig.materialized; yield* recordOrioleDbTelemetry( validatedConfig.db.orioledb_version, validatedConfig.db.major_version, @@ -980,31 +295,32 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const validationError = configValidationError(validatedConfig); if (validationError !== undefined) return yield* new StackConfigError({ message: validationError }); - if (context.loaded === null) + if (!resolvedConfig.hasConfigFile) yield* rejectFrozenWithoutConfig(projectRoot, opts?.frozen === true); const externalProviders = yield* Effect.try({ try: () => - resolveAuthExternalProviders( - section(context.loaded?.document, "auth"), - validatedConfig.auth.external, - context.projectEnvValues, - ), + resolveAuthExternalProviders(section(document, "auth"), validatedConfig.auth.external), catch: (cause) => new StackConfigError({ message: cause instanceof Error ? cause.message : "invalid auth provider config", }), }); + const authExternalUrl = Option.getOrUndefined( + (yield* resolvedConfig + .get(CliConfigKeys.auth.externalUrl) + .pipe(Effect.mapError(toStackConfigError))).value, + ); const authConfig = yield* resolveAuthConfig( validatedConfig.auth, validatedConfig.local_smtp, { - authExternalUrl: resolveAuthExternalUrl( - context.loaded?.document, - context.projectEnvValues, - ), + smtpEnabled: resolveSmtpEnabled(resolvedConfig), + authExternalUrl, apiExternalUrl: validatedConfig.api.external_url, externalProviders, - ...resolveGotruePasskeyWebauthn(context.loaded?.document, context.projectEnvValues), + ...(yield* resolvePasskeyWebauthn(resolvedConfig).pipe( + Effect.mapError(toStackConfigError), + )), }, ); const path = yield* Path.Path; @@ -1022,40 +338,43 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( message: cause instanceof Error ? cause.message : String(cause), }), }); + const stackKeyError = (cause: unknown) => + cause instanceof StackConfigError + ? cause + : new StackConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }); const localKeys = yield* Effect.cached( - Effect.try({ - try: () => { - const configured = (value: string | undefined) => - value === undefined || value === "" - ? undefined - : decryptAuthSecret(value, context.projectEnvValues); - const publishableKey = configured(auth.publishable_key); - const secretKey = configured(auth.secret_key); - const configuredAnonKey = configured(auth.anon_key); - const configuredServiceRoleKey = configured(auth.service_role_key); - const configuredSigningKeys = resolveConfiguredSigningKeys( - validatedConfig, - projectRoot, - context.projectEnvValues, - ); - const signingKeys = - configuredSigningKeys ?? - (auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 - ? undefined - : [DEFAULT_SIGNING_KEY]); - const signingKey = signingKeys?.[0]; - return { + Effect.gen(function* () { + const configured = (value: string | undefined) => + value === undefined || value === "" ? undefined : value; + const publishableKey = configured(auth.publishable_key); + const secretKey = configured(auth.secret_key); + const configuredAnonKey = configured(auth.anon_key); + const configuredServiceRoleKey = configured(auth.service_role_key); + const configuredSigningKeys = yield* resolveConfiguredSigningKeys( + validatedConfig, + projectRoot, + ); + const signingKeys = + configuredSigningKeys ?? + (auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 + ? undefined + : [DEFAULT_SIGNING_KEY]); + const signingKey = signingKeys?.[0]; + return yield* Effect.try({ + try: () => ({ ...(publishableKey === undefined ? {} : { publishableKey }), ...(secretKey === undefined ? {} : { secretKey }), ...(configuredAnonKey === undefined ? signingKey === undefined ? {} - : { anonKey: generateAsymmetricGoJwt(signingKey, "anon") } + : { anonKey: generateAsymmetricLocalJwt(signingKey, "anon") } : { anonKey: configuredAnonKey }), ...(configuredServiceRoleKey === undefined ? signingKey === undefined ? {} - : { serviceRoleKey: generateAsymmetricGoJwt(signingKey, "service_role") } + : { serviceRoleKey: generateAsymmetricLocalJwt(signingKey, "service_role") } : { serviceRoleKey: configuredServiceRoleKey }), anonKeyIsOverride: configuredAnonKey !== undefined, serviceRoleKeyIsOverride: configuredServiceRoleKey !== undefined, @@ -1065,13 +384,14 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( gotrueJwtKeys: encodeJwkArray(signingKeys), publicSigningKeys: encodeJwkArray(signingKeys.map(toPublicJwk)), }), - }; - }, - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : String(cause), }), - }), + catch: stackKeyError, + }); + }).pipe( + Effect.mapError(stackKeyError), + Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem), + Effect.provideService(Path.Path, path), + ), ); const remoteJwks = Effect.gen(function* () { return issuer === undefined @@ -1094,12 +414,8 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }); const functionEnvironments = Object.fromEntries( yield* Effect.forEach(Object.entries(validatedConfig.functions), ([name, config]) => - resolveCliConfigSubtree( - config.env, - { values: context.projectEnvValues }, - `functions.${name}.env`, - { goViperCompat: true }, - ).pipe( + resolveConfigSubtree(resolvedConfig, config.env, `functions.${name}.env`).pipe( + Effect.mapError((error) => new StackConfigError({ message: error.message })), Effect.map( (env) => [ @@ -1151,23 +467,27 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), catch: (cause) => new StackConfigError({ message: String(cause) }), }); - const functionsEnv = yield* Effect.try({ - try: () => - Object.fromEntries( - Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ - key, - decryptAuthSecret(value, context.projectEnvValues) ?? "", - ]), - ), - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); + const functionsEnvResult = decryptSecretMap( + Object.fromEntries( + Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ + key, + value ?? "", + ]), + ), + resolvedConfig.dotenvPrivateKeys, + ); + if (!functionsEnvResult.ok) + return yield* new StackConfigError({ + message: `failed to parse config: ${functionsEnvResult.error}`, + }); + const functionsEnv = functionsEnvResult.value; const storageFileSizeLimit = yield* Effect.try({ try: () => String(parseFileSizeLimit(validatedConfig.storage.file_size_limit)), catch: (cause) => new StackConfigError({ message: `Invalid storage.file_size_limit: ${String(cause)}` }), }); const healthTimeoutMs = yield* Effect.try({ - try: () => parseGoDuration(validatedConfig.db.health_timeout) / 1_000_000, + try: () => parseDuration(validatedConfig.db.health_timeout) / 1_000_000, catch: (cause) => new StackConfigError({ message: `Invalid db.health_timeout: ${String(cause)}` }), }); @@ -1180,71 +500,31 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }); const jwtSecret = configuredJwtSecret === undefined ? undefined : Redacted.make(configuredJwtSecret); - const document = context.loaded?.document; - const rootKey = yield* Effect.try({ - try: () => { - const raw = section(document, "db")?.root_key; - if (raw !== undefined && typeof raw !== "string") - throw new Error("db.root_key must be a string"); - const value = decryptAuthSecret( - envOverride("SUPABASE_DB_ROOT_KEY", raw, context.projectEnvValues), - context.projectEnvValues, - ); - return value === undefined || value === "" ? undefined : value; - }, - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); - const dbPort = resolvePort( - DB_PORT, - document, - validatedConfig.db.port, - context.projectEnvValues, - ); - const apiPort = resolvePort( - API_PORT, - document, - validatedConfig.api.port, - context.projectEnvValues, - ); - const studioPort = resolvePort( - STUDIO_PORT, - document, - validatedConfig.studio.port, - context.projectEnvValues, - ); - const poolerPort = resolvePort( - DB_POOLER_PORT, - document, - validatedConfig.db.pooler.port, - context.projectEnvValues, - ); - const mailPort = resolvePort( - LOCAL_SMTP_PORT, - document, - validatedConfig.local_smtp.port, - context.projectEnvValues, + const rootKey = yield* resolvedConfig.get(CliConfigKeys.db.rootKey).pipe( + Effect.map(({ value, origin }) => + origin.tier === "default" || value === "" ? undefined : value, + ), + Effect.mapError(toStackConfigError), ); - const mailSmtpPort = resolvePort( - LOCAL_SMTP_SMTP_PORT, - document, + const configuredPort = (path: string, port: number) => + originAt(path).tier === "default" ? undefined : port; + const dbPort = configuredPort("db.port", validatedConfig.db.port); + const apiPort = configuredPort("api.port", validatedConfig.api.port); + const studioPort = configuredPort("studio.port", validatedConfig.studio.port); + const poolerPort = configuredPort("db.pooler.port", validatedConfig.db.pooler.port); + const mailPort = configuredPort("local_smtp.port", validatedConfig.local_smtp.port); + const mailSmtpPort = configuredPort( + "local_smtp.smtp_port", validatedConfig.local_smtp.smtp_port ?? 0, - context.projectEnvValues, ); - const mailPop3Port = resolvePort( - LOCAL_SMTP_POP3_PORT, - document, + const mailPop3Port = configuredPort( + "local_smtp.pop3_port", validatedConfig.local_smtp.pop3_port ?? 0, - context.projectEnvValues, - ); - const analyticsPort = resolvePort( - ANALYTICS_PORT, - document, - validatedConfig.analytics.port, - context.projectEnvValues, ); + const analyticsPort = configuredPort("analytics.port", validatedConfig.analytics.port); const poolMode = validatedConfig.db.pooler.pool_mode === "session" ? ("session" as const) : "transaction"; - const selection = yield* context.loaded !== null && opts?.selection !== undefined + const selection = yield* resolvedConfig.hasConfigFile && opts?.selection !== undefined ? (opts.publishesLock === true ? localKeys : Effect.void).pipe( Effect.andThen( opts.selection({ @@ -1410,11 +690,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( endpoints: { http: endpoint(apiPort), inspector: endpoint( - resolvePort( - EDGE_RUNTIME_INSPECTOR_PORT, - document, + configuredPort( + "edge_runtime.inspector_port", validatedConfig.edge_runtime.inspector_port, - context.projectEnvValues, ), ), }, @@ -1464,10 +742,10 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( selection, creations: createCreations, source: validatedConfig, - projectEnvValues: context.projectEnvValues, + projectEnvValues, + originAt, remoteJwks, keys, - ...(context.loaded?.document === undefined ? {} : { document: context.loaded.document }), }; }), ); diff --git a/apps/cli/src/command-internal/stack-functions-env.ts b/apps/cli/src/command-internal/stack-functions-env.ts index 27f55fe62f..4d7c33290e 100644 --- a/apps/cli/src/command-internal/stack-functions-env.ts +++ b/apps/cli/src/command-internal/stack-functions-env.ts @@ -1,6 +1,6 @@ import { Data, Effect, FileSystem, Predicate } from "effect"; import type { ServiceCreationInput } from "@supabase/stack/effect"; -import { parseDotEnv } from "./dotenv.ts"; +import { parseDotEnv } from "../shared/config/dotenv.ts"; import { actionability, type CliErrorActionabilityDeclaration, diff --git a/apps/cli/src/command-internal/stack-lock.ts b/apps/cli/src/command-internal/stack-lock.ts index b44b79db70..2cd70b06bc 100644 --- a/apps/cli/src/command-internal/stack-lock.ts +++ b/apps/cli/src/command-internal/stack-lock.ts @@ -41,7 +41,7 @@ import { } from "effect"; import { FetchHttpClient } from "effect/http"; -import { ensureGitignore, readProjectLink } from "./project-link.ts"; +import { ensureGitignore, readProjectLink } from "../shared/config/project-link.ts"; import { stackArtifactSelection } from "./stack-artifact-selection.ts"; import { StackConfigError } from "./stack-config.ts"; import { warnDataConflicts } from "./stack-data-conflicts.ts"; diff --git a/apps/cli/src/command-internal/stack-shadow-cache.ts b/apps/cli/src/command-internal/stack-shadow-cache.ts index a28669d8b9..6c49ddfddd 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.ts @@ -7,7 +7,7 @@ import { type ShadowCacheKeyInputs, } from "./db-bootstrap/shadow-cache.ts"; import { resolveSetupWebhooksEnabled, type SetupDatabaseOptions } from "./db-bootstrap/db-setup.ts"; -import { viperEnvBoolWithProjectFallback } from "./viper-env.ts"; +import { supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; const encodeKeyParts = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); @@ -38,9 +38,13 @@ export const stackShadowCacheEntry = Effect.fn("StackShadowCache.entry")(functio ) { if ( bypassCache || - !(yield* viperEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { - whenUnset: true, - })) + !(yield* supabaseEnvBoolWithProjectFallback( + SHADOW_CACHE_ENV, + input.setup.projectEnvValues ?? {}, + { + whenUnset: true, + }, + )) ) return undefined; const rolesSql = yield* readRoles(input); diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 3c75e7cd31..d16481ab95 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, FileSystem, Layer, Option, Path, Ref, Schema } from "effect"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; @@ -666,6 +667,7 @@ describe("stack shadow databases", () => { const settings = Layer.mergeAll( runtimeInfoLayer, mockCommandSettings({ workdir: root, supabaseHome: root }), + cliConfigValuesTestLayer, ); const locked = unbundled(selection.database); yield* seedLock(root, (services) => ({ @@ -675,7 +677,7 @@ describe("stack shadow databases", () => { const context = yield* loadLocalProjectContext(root, (message) => message, ref).pipe( Effect.provide(settings), ); - expect(context.loaded?.appliedRemote).toBeDefined(); + expect(Option.isSome(context.resolvedConfig.appliedRemote)).toBe(true); expect(context.config.db.major_version).toBe(remote.major); expect(context.config.db.orioledb_version).toBe(remote.db.orioledb_version); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index 442d62a99c..faa3ce1620 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Path, Redacted, Result, Schema } from "effect"; +import { Effect, FileSystem, Option, Path, Redacted, Result, Schema } from "effect"; import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; import type { ArtifactPin, ArtifactSelection } from "@supabase/stack/internal/artifacts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -241,7 +241,10 @@ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(funct majorVersion: input.setup.majorVersion, orioledbVersion: input.db.orioledb_version, services: input.context?.config.local?.services, - appliedRemote: input.context?.loaded?.appliedRemote, + appliedRemote: + input.context === undefined + ? undefined + : Option.getOrUndefined(input.context.resolvedConfig.appliedRemote), }).pipe( Effect.mapError( (error) => diff --git a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts index 1c29a13e9d..77f0170d10 100644 --- a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts +++ b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts @@ -28,6 +28,7 @@ import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; import { ExperimentalFlag, YesFlag } from "./global-flags.ts"; import { stackStart } from "../commands/experimental/stack/start/start.handler.ts"; import { destroyTestStacks } from "../../tests/helpers/stack-cleanup.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; const projectConfig = ` project_id = "stack-storage-native-integration" @@ -80,6 +81,7 @@ const makeLayers = (root: string) => { const telemetry = mockTelemetryStateTracked(); const tty = mockTty({ stdinIsTty: false, stdoutIsTty: false }); return Layer.mergeAll( + cliConfigValuesTestLayer, FetchHttpClient.layer, runtimeInfoLayer, settings, diff --git a/apps/cli/src/command-internal/start-secrets-cleanup.ts b/apps/cli/src/command-internal/start-secrets-cleanup.ts index daf10a24cb..bca8b82bee 100644 --- a/apps/cli/src/command-internal/start-secrets-cleanup.ts +++ b/apps/cli/src/command-internal/start-secrets-cleanup.ts @@ -1,6 +1,6 @@ import { Effect, FileSystem, Path } from "effect"; -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; +import { legacyProjectStatePaths } from "../shared/config/legacy-project-state.paths.ts"; import type { ContainerIdName } from "./docker-lifecycle.ts"; diff --git a/apps/cli/src/command-internal/status-errors.ts b/apps/cli/src/command-internal/status-errors.ts index 3da9741a59..5f5e8e382f 100644 --- a/apps/cli/src/command-internal/status-errors.ts +++ b/apps/cli/src/command-internal/status-errors.ts @@ -4,6 +4,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { aqua } from "./colors.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a directory. Checked @@ -17,7 +18,7 @@ export class StatusWorkdirError extends Data.TaggedError("StatusWorkdirError")<{ } } -/** `loadCliConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +/** The config load rejected `supabase/config.toml` (malformed TOML/JSON). */ export class StatusConfigLoadError extends Data.TaggedError("StatusConfigLoadError")<{ readonly message: string; }> { @@ -51,12 +52,20 @@ export class StatusDbInspectError extends Data.TaggedError("StatusDbInspectError /** The db container is absent or present but not in the `running` state. */ export class StatusDbNotRunningError extends Data.TaggedError("StatusDbNotRunningError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.startStack; + return this.suggestion === undefined ? actionability.startStack : actionability.stopStack; } } +/** The db container exists but is not running; `supabase stop` clears the stopped project. */ +export const dbNotRunningError = (containerId: string, status: string) => + new StatusDbNotRunningError({ + message: `${containerId} container is not running: ${status || "unknown"}`, + suggestion: `Run ${aqua("supabase stop")} to remove this project's stopped containers, then run ${aqua("supabase start")} again.`, + }); + /** The db container is running but its Docker health check is not `healthy`. */ export class StatusDbNotReadyError extends Data.TaggedError("StatusDbNotReadyError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/status-pretty.ts b/apps/cli/src/command-internal/status-pretty.ts index 62ab0232d5..ec400a0d9a 100644 --- a/apps/cli/src/command-internal/status-pretty.ts +++ b/apps/cli/src/command-internal/status-pretty.ts @@ -78,7 +78,7 @@ export function statusGroups( /** * Display width for this command's inputs: URLs/keys/labels are always plain ASCII, so every - * rune is width 1. The fixed group-title emoji are the only non-ASCII runes ever rendered, + * character is width 1. The fixed group-title emoji are the only non-ASCII characters ever rendered, * and their widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} instead of computed * generically. */ diff --git a/apps/cli/src/command-internal/status-pretty.unit.test.ts b/apps/cli/src/command-internal/status-pretty.unit.test.ts index c98a8cb76b..f410a98633 100644 --- a/apps/cli/src/command-internal/status-pretty.unit.test.ts +++ b/apps/cli/src/command-internal/status-pretty.unit.test.ts @@ -45,7 +45,7 @@ const FULL_VALUES: Record = { }; describe("renderStatusPretty", () => { - it("matches the Go rounded-table fixture for a fully running stack", () => { + it("matches the rounded-table fixture for a fully running stack", () => { const out = stripAnsi(renderStatusPretty(FULL_VALUES, NAMES)); const expected = [ @@ -94,7 +94,7 @@ describe("renderStatusPretty", () => { }); // All other groups are empty in this fixture, so only the Database box should appear. - it("matches the Go rounded-table fixture for a single-row group", () => { + it("matches the rounded-table fixture for a single-row group", () => { const out = stripAnsi( renderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), ); @@ -112,7 +112,7 @@ describe("renderStatusPretty", () => { }); // All other groups are empty in this fixture; only Project URL is present in APIs. - it("matches the Go rounded-table fixture for a partial APIs group", () => { + it("matches the rounded-table fixture for a partial APIs group", () => { const out = stripAnsi(renderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); const expectedTable = [ diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index ff8240a8cd..530e8146a9 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -1,17 +1,14 @@ import type { CliConfig } from "@supabase/config"; +import { Effect } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { serviceContainerIds } from "./docker-ids.ts"; -import { - envOverrideBool, - resolveLocalConfigValues, - type LocalConfigValues, -} from "./local-config-values.ts"; +import { resolveLocalConfigValues, type LocalConfigValues } from "./local-config-values.ts"; /** * A status output field: the dotted key `--override-name =` matches against * (`fieldKey`), and its default output env-var name (`defaultName`). Deprecated fields - * (`inbucket`, `jwt_secret`, `anon_key`, `service_role_key`) are still emitted. + * (`jwt_secret`, `anon_key`, `service_role_key`) are still emitted. */ export interface StatusField { readonly fieldKey: string; @@ -32,7 +29,6 @@ const FUNCTIONS_URL: StatusField = { }; const DB_URL: StatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; const STUDIO_URL: StatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; -const INBUCKET_URL: StatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; const MAILPIT_URL: StatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; const PUBLISHABLE_KEY: StatusField = { fieldKey: "auth.publishable_key", @@ -58,7 +54,7 @@ const STORAGE_S3_REGION: StatusField = { defaultName: "S3_PROTOCOL_REGION", }; -/** All 18 fields, in declaration order. */ +/** All 17 fields, in declaration order. */ export const STATUS_FIELDS: ReadonlyArray = [ API_URL, REST_URL, @@ -68,7 +64,6 @@ export const STATUS_FIELDS: ReadonlyArray = [ FUNCTIONS_URL, DB_URL, STUDIO_URL, - INBUCKET_URL, MAILPIT_URL, PUBLISHABLE_KEY, SECRET_KEY, @@ -209,7 +204,7 @@ export interface StatusState { } /** - * The validating half of {@link StatusState}: everything that can throw, and none of it + * The validating half of {@link StatusState}: everything that can fail, and none of it * depends on `excluded`/`containerIds`. Split out so `status.handler.ts` can resolve and * validate this before any Docker call — a bad `auth.jwt_secret` or malformed * `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override must fail here, not be masked by a @@ -228,21 +223,16 @@ export interface StatusLocalState { } /** - * Resolves local config values (URLs, keys — can throw, see {@link resolveLocalConfigValues}) + * Resolves local config values (URLs, keys — can fail, see {@link resolveLocalConfigValues}) * and the per-service `.enabled` gates, with no reference to `excluded`/`containerIds` — see - * {@link gateStatusState} for the Docker-dependent half this composes with. Each `.enabled` - * gate is read through {@link envOverrideBool}, not the raw decoded `config.
.enabled`, - * so an env-overridden stack's running services match what `status` reports. + * {@link gateStatusState} for the Docker-dependent half this composes with. `config` must be + * the resolved config's materialized config so env overrides are already applied to every gate. * * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override - * doesn't parse as a valid bool. * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, or its - * first key is unsupported — see {@link generateAsymmetricGoJwt}. + * first key is unsupported — see {@link generateAsymmetricLocalJwt}. */ -export function resolveStatusLocalState( +export const resolveStatusLocalState = Effect.fnUntraced(function* ( config: CliConfig, hostname: string, workdir: string, @@ -253,58 +243,22 @@ export function resolveStatusLocalState( * An already-resolved {@link resolveLocalConfigValues} result to reuse instead of * re-deriving one. Callers that resolved `local` earlier in the same process (e.g. * `start`'s success-path status print) must pass it here: a second call re-mints a - * time-dependent asymmetric JWT (`auth.signing_keys_path` + {@link generateAsymmetricGoJwt}'s + * time-dependent asymmetric JWT (`auth.signing_keys_path` + {@link generateAsymmetricLocalJwt}'s * `exp` claim) with a different signature than the one baked into the already-running * containers. */ precomputedLocal?: LocalConfigValues, -): StatusLocalState { +) { const local = - precomputedLocal ?? - resolveLocalConfigValues(config, hostname, workdir, projectEnvValues, document); + precomputedLocal ?? (yield* resolveLocalConfigValues(config, hostname, workdir, document)); - const apiEnabled = envOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const studioSectionEnabled = envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const authSectionEnabled = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const inbucketSectionEnabled = envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const storageSectionEnabled = envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ); - const edgeRuntimeEnabled = envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ); - const storageS3ProtocolEnabled = envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ); + const apiEnabled = config.api.enabled; + const studioSectionEnabled = config.studio.enabled; + const authSectionEnabled = config.auth.enabled; + const inbucketSectionEnabled = config.local_smtp.enabled; + const storageSectionEnabled = config.storage.enabled; + const edgeRuntimeEnabled = config.edge_runtime.enabled; + const storageS3ProtocolEnabled = config.storage.s3_protocol.enabled; return { config, @@ -316,14 +270,14 @@ export function resolveStatusLocalState( storageSectionEnabled, edgeRuntimeEnabled, storageS3ProtocolEnabled, - }; -} + } satisfies StatusLocalState; +}); /** * The Docker-dependent, non-throwing half of status resolution: applies `excluded`, matching * each gated service by its container id ({@link statusContainerIds}) or its default Docker * image short name ({@link shortContainerImageName}) — service images are never - * user-overridable, so the default is always the one to check. Pure: every throwing concern + * user-overridable, so the default is always the one to check. Pure: every fallible concern * already ran in {@link resolveStatusLocalState}. */ export function gateStatusState( @@ -411,7 +365,6 @@ export function statusValuesFromState( } if (inbucketEnabled) { values[names.mailpitUrl] = local.mailpitUrl; - values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; } if (storageEnabled && storageS3ProtocolEnabled) { values[names.storageS3Url] = local.storageS3Url; @@ -423,23 +376,13 @@ export function statusValuesFromState( return { values, names, local }; } -/** The stderr warning for the deprecated `INBUCKET_URL` field, when {@link statusValuesFromState} emits it. */ -export function deprecatedStatusFieldWarning( - state: StatusState, - overrides: ReadonlyMap, -): string | undefined { - if (!state.inbucketEnabled) return undefined; - const nameFor = (field: StatusField) => overrides.get(field.fieldKey) ?? field.defaultName; - return `WARN: ${nameFor(INBUCKET_URL)} is deprecated and will be removed in v3, use ${nameFor(MAILPIT_URL)} instead.\n`; -} - /** * Convenience wrapper combining {@link resolveStatusLocalState}, {@link gateStatusState}, and * {@link statusValuesFromState} in one call — used by tests needing only a single override * map. `status.handler.ts` calls the three separately so it can validate before any Docker * call and reuse the gated state for both the real and pretty-mode value maps. */ -export function statusValues( +export const statusValues = Effect.fnUntraced(function* ( config: CliConfig, containerIds: StatusContainerIds, hostname: string, @@ -449,8 +392,14 @@ export function statusValues( projectEnvValues?: Readonly>, /** `LoadedCliConfig.document` — see {@link resolveLocalConfigValues}'s doc comment. */ document?: Readonly>, -): StatusValuesResult { - const localState = resolveStatusLocalState(config, hostname, workdir, projectEnvValues, document); +) { + const localState = yield* resolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); const state = gateStatusState(localState, containerIds, excluded); return statusValuesFromState(state, overrides); -} +}); diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index 2ef704e793..e4cb1956e3 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -1,6 +1,7 @@ +import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { shortContainerImageName, @@ -30,6 +31,9 @@ const NONE: ReadonlyArray = []; const NO_OVERRIDES = new Map(); const WORKDIR = "/tmp/status-values-test"; +const run = (effect: Effect.Effect) => + Effect.runSync(effect.pipe(Effect.provide(BunServices.layer))); + describe("statusValues", () => { it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { const config = baseConfig({ @@ -40,13 +44,8 @@ describe("statusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.kong], NO_OVERRIDES, WORKDIR), ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); @@ -54,20 +53,17 @@ describe("statusValues", () => { describe("api / kong gating", () => { it("includes API_URL when api.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeDefined(); }); it("keeps the Kong URLs but omits REST/GraphQL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.API_URL).toBeDefined(); expect(values.FUNCTIONS_URL).toBeDefined(); expect(values.MCP_URL).toBeDefined(); @@ -76,50 +72,51 @@ describe("statusValues", () => { }); it("omits API_URL when the kong container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.API_URL).toBeUndefined(); }); it("omits API_URL when the kong image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["kong"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["kong"], NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeUndefined(); }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); it("omits REST/GraphQL when only the rest container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.rest], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -127,26 +124,16 @@ describe("statusValues", () => { }); it("includes REST/GraphQL when kong and postgrest are both enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); }); it("omits REST/GraphQL when the postgrest image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["postgrest"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["postgrest"], NO_OVERRIDES, WORKDIR), ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -156,43 +143,44 @@ describe("statusValues", () => { describe("functions gating", () => { it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.FUNCTIONS_URL).toBeDefined(); }); it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.edgeRuntime], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -200,13 +188,15 @@ describe("statusValues", () => { it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile build // alias (`edgeruntime`); the short name matched against is the former. - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["edge-runtime"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -214,87 +204,75 @@ describe("statusValues", () => { describe("studio / mcp gating", () => { it("includes STUDIO_URL when studio.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.STUDIO_URL).toBeDefined(); }); it("omits STUDIO_URL when studio.enabled is false", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.studio], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["studio"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["studio"], NO_OVERRIDES, WORKDIR), ); expect(values.STUDIO_URL).toBeUndefined(); }); it("includes MCP_URL only when both kong and studio are enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.MCP_URL).toBeDefined(); }); it("omits MCP_URL when kong is disabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.kong], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.MCP_URL).toBeUndefined(); }); it("omits MCP_URL when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.MCP_URL).toBeUndefined(); }); }); describe("auth gating", () => { it("includes all 5 auth fields when auth.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.SECRET_KEY).toBeDefined(); @@ -305,7 +283,9 @@ describe("statusValues", () => { it("omits all 5 auth fields when auth.enabled is false", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); expect(values.SECRET_KEY).toBeUndefined(); expect(values.JWT_SECRET).toBeUndefined(); @@ -314,71 +294,61 @@ describe("statusValues", () => { }); it("omits all 5 auth fields when the auth container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.auth], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); it("omits all 5 auth fields when the gotrue image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["gotrue"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["gotrue"], NO_OVERRIDES, WORKDIR), ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); }); describe("inbucket/mailpit gating", () => { - it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + it("emits MAILPIT_URL and no INBUCKET_URL when local_smtp.enabled", () => { + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.MAILPIT_URL).toBeDefined(); - expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + expect(values.INBUCKET_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + it("omits MAILPIT_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.MAILPIT_URL).toBeUndefined(); - expect(values.INBUCKET_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.inbucket], - NO_OVERRIDES, - WORKDIR, + it("omits MAILPIT_URL when the inbucket container id is excluded", () => { + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.MAILPIT_URL).toBeUndefined(); }); - it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["mailpit"], - NO_OVERRIDES, - WORKDIR, + it("omits MAILPIT_URL when the mailpit image short name is excluded", () => { + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["mailpit"], NO_OVERRIDES, WORKDIR), ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -386,13 +356,8 @@ describe("statusValues", () => { describe("storage / s3 gating", () => { it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeDefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); @@ -402,18 +367,22 @@ describe("statusValues", () => { it("omits storage S3 fields when storage.enabled is false", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), + ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when the storage container id is excluded", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [CONTAINER_IDS.storage], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + WORKDIR, + ), ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -421,195 +390,27 @@ describe("statusValues", () => { it("omits storage S3 fields when the storage-api image short name is excluded", () => { // The image repo name (`supabase/storage-api`) differs from the Dockerfile build // alias (`storage`); the short name matched against is the former. - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - ["storage-api"], - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, ["storage-api"], NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); - expect(values.STORAGE_S3_URL).toBeUndefined(); - expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); - }); - }); - - describe("SUPABASE_*_ENABLED env overrides", () => { - // `resolveStatusLocalState` must read the post-env-override value for every `.enabled` - // gate, not the raw decoded `config.
.enabled`. - - it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { - const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_API_ENABLED: "true", - }, - ); - expect(values.API_URL).toBeDefined(); - expect(values.REST_URL).toBeDefined(); - }); - - it("omits REST_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_API_ENABLED: "false" }, - ); - expect(values.REST_URL).toBeUndefined(); - expect(values.API_URL).toBeDefined(); - }); - - it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { - const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_STUDIO_ENABLED: "true", - }, - ); - expect(values.STUDIO_URL).toBeDefined(); - }); - - it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { - const config = baseConfig({ auth: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_AUTH_ENABLED: "true", - }, - ); - expect(values.PUBLISHABLE_KEY).toBeDefined(); - expect(values.ANON_KEY).toBeDefined(); - expect(values.SERVICE_ROLE_KEY).toBeDefined(); - }); - - it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_AUTH_ENABLED: "false" }, - ); - expect(values.PUBLISHABLE_KEY).toBeUndefined(); - }); - - it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { - const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_LOCAL_SMTP_ENABLED: "true", - }, - ); - expect(values.MAILPIT_URL).toBeDefined(); - }); - - it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { - const config = baseConfig({ storage: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_STORAGE_ENABLED: "true", - }, - ); - expect(values.STORAGE_S3_URL).toBeDefined(); - }); - - it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { - const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_EDGE_RUNTIME_ENABLED: "true", - }, - ); - expect(values.FUNCTIONS_URL).toBeDefined(); - }); - - it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { - const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "true", - }, - ); - expect(values.STORAGE_S3_URL).toBeDefined(); - }); - - it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + const { values } = run( + statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); }); }); describe("--override-name remapping", () => { it("remaps a field's output KEY while leaving the value unchanged", () => { const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.API_URL).toBeUndefined(); expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); @@ -620,13 +421,8 @@ describe("statusValues", () => { ["api.url", "CUSTOM_API_URL"], ["db.url", "CUSTOM_DB_URL"], ]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.CUSTOM_API_URL).toBeDefined(); expect(values.CUSTOM_DB_URL).toBeDefined(); @@ -636,13 +432,8 @@ describe("statusValues", () => { it("leaves unrelated fields at their default name when only one is overridden", () => { const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.REST_URL).toBeDefined(); }); @@ -653,13 +444,8 @@ describe("statusValues", () => { ["auth.anon_key", "CUSTOM_ANON_KEY"], ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], ]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR), ); expect(values.CUSTOM_JWT_SECRET).toBeDefined(); expect(values.CUSTOM_ANON_KEY).toBeDefined(); @@ -668,33 +454,13 @@ describe("statusValues", () => { expect(values.ANON_KEY).toBeUndefined(); expect(values.SERVICE_ROLE_KEY).toBeUndefined(); }); - - it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { - const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - overrides, - WORKDIR, - ); - expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); - expect(values.MAILPIT_URL).toBeDefined(); - expect(values.INBUCKET_URL).toBeUndefined(); - }); }); it("combines stopped-service exclusions with --exclude flag exclusions", () => { // Both `stopped` and `--exclude` funnel into the same `excluded` array in the handler. const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - excluded, - NO_OVERRIDES, - WORKDIR, + const { values } = run( + statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, excluded, NO_OVERRIDES, WORKDIR), ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.STUDIO_URL).toBeUndefined(); @@ -726,14 +492,9 @@ describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { ["studio", "STUDIO_URL"], ["edge-runtime", "FUNCTIONS_URL"], ] as const) { - const { values } = slimModule.statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [excluded], - NO_OVERRIDES, - WORKDIR, - ); + const { values } = yield* slimModule + .statusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, [excluded], NO_OVERRIDES, WORKDIR) + .pipe(Effect.provide(BunServices.layer)); expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); } }), diff --git a/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts b/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts index 042e29ec97..c33d935f48 100644 --- a/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts +++ b/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts @@ -15,7 +15,7 @@ describe("parseFileSizeLimit", () => { expect(() => parseFileSizeLimit("not-a-size")).toThrow(); }); - it("accepts Go-valid numeral forms (strconv.ParseFloat parity)", () => { + it("accepts valid numeral forms", () => { expect(parseFileSizeLimit(".5MiB")).toBe(Math.trunc(0.5 * 1024 * 1024)); expect(parseFileSizeLimit("1.MiB")).toBe(1024 * 1024); expect(parseFileSizeLimit("1e6")).toBe(1_000_000); @@ -23,7 +23,7 @@ describe("parseFileSizeLimit", () => { expect(parseFileSizeLimit("1_0MiB")).toBe(10 * 1024 * 1024); }); - it("rejects badly-placed underscores (Go literal rule)", () => { + it("rejects badly-placed underscores", () => { expect(() => parseFileSizeLimit("_1000MiB")).toThrow("invalid size"); expect(() => parseFileSizeLimit("1__0MiB")).toThrow("invalid size"); }); @@ -34,7 +34,7 @@ describe("parseFileSizeLimit", () => { expect(() => parseFileSizeLimit("-5MiB")).toThrow("invalid size"); }); - it("rejects an overflowing numeral (Go ParseFloat range error)", () => { + it("rejects an overflowing numeral", () => { expect(() => parseFileSizeLimit("1e309")).toThrow("invalid size"); }); }); diff --git a/apps/cli/src/command-internal/storage-content-type.unit.test.ts b/apps/cli/src/command-internal/storage-content-type.unit.test.ts index dc249caa1c..fac59a79f7 100644 --- a/apps/cli/src/command-internal/storage-content-type.unit.test.ts +++ b/apps/cli/src/command-internal/storage-content-type.unit.test.ts @@ -68,13 +68,11 @@ layer(BunPath.layer)("contentTypeForUpload", (it) => { }); layer(BunPath.layer)("refineUploadContentType", (it) => { - it.effect( - "refines an explicit text/plain content-type by extension (Go refines the flag too)", - () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(refineUploadContentType(path, "text/plain", "/x/a.json")).toBe("application/json"); - }), + it.effect("refines an explicit text/plain content-type by extension", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(refineUploadContentType(path, "text/plain", "/x/a.json")).toBe("application/json"); + }), ); it.effect("leaves a non-text content-type untouched", () => diff --git a/apps/cli/src/command-internal/storage-credentials.integration.test.ts b/apps/cli/src/command-internal/storage-credentials.integration.test.ts index a7b5d4e044..4a8448d787 100644 --- a/apps/cli/src/command-internal/storage-credentials.integration.test.ts +++ b/apps/cli/src/command-internal/storage-credentials.integration.test.ts @@ -7,6 +7,7 @@ import { mockCommandPlatformApi, mockCommandSettings, } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; @@ -16,18 +17,18 @@ const apiKeys = [ { name: "anon", api_key: "anon-key", type: "legacy" }, { name: "service_role", api_key: "service-role-key", type: "legacy" }, ]; -const config = { api: { enabled: true, port: 54321, tls: { enabled: false } }, auth: {} }; describe("resolveStorageCredentials for a remote project", () => { const resolve = ( api: ReturnType, env: Record = {}, ) => - resolveStorageCredentials({ projectRef: VALID_REF, config }).pipe( + resolveStorageCredentials({ projectRef: VALID_REF }).pipe( Effect.provide( Layer.mergeAll( ConfigProvider.layer(ConfigProvider.fromEnvRecord(env)), api.factoryLayer, + cliConfigValuesTestLayer, mockCommandSettings({ workdir: "/unused" }), mockRuntimeInfo(), unusedStackServices, diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index 0b4158db5f..29fb839fcf 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -1,21 +1,18 @@ -import { Config, Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Result } from "effect"; import { FetchHttpClient } from "effect/http"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { pickCliEnvName } from "../config/cli-config-key.ts"; +import { CliEnvNames } from "../config/cli-config-keys.ts"; +import { readShellEnvironment } from "../shared/config/cli-config-env.ts"; import { resolveApiExternalUrl } from "./api-url.ts"; import { validateApiPort, validateApiTlsPresence } from "./config-validate.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; +import { describeConfigLoadFailure, loadResolvedConfigContext } from "./resolved-config-context.ts"; import { mapTenantApiKeysError } from "./get-tenant-api-keys.ts"; -import { generateGoJwt } from "./go-jwt.ts"; +import { generateLocalJwt } from "./local-jwt.ts"; import { getHostname } from "./hostname.ts"; -import { - decryptAuthSecret, - envOverride, - envOverrideBool, - envOverridePort, - resolveJwtSecret, -} from "./local-config-values.ts"; +import { resolveJwtSecret } from "./local-config-values.ts"; import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts"; import { extractServiceKeys } from "./tenant-keys.ts"; import { @@ -78,28 +75,27 @@ export interface StorageCredentials { export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { readonly projectRef: string; - readonly config: StorageConfigView; - /** - * Already-resolved project env map for the `SUPABASE_API_*`/`SUPABASE_AUTH_*` - * overrides, when the caller has one in scope. When omitted, this loads the - * project dotenv itself. - */ - readonly projectEnvValues?: Readonly>; }) { const cliSettings = yield* CommandSettings; if (opts.projectRef !== "") { const baseUrl = `https://${opts.projectRef}.${cliSettings.projectHost}`; - const envKey = yield* Config.option(Config.String("SUPABASE_AUTH_SERVICE_ROLE_KEY")).pipe( - Effect.mapError( - () => - new StorageConfigError({ - message: "failed to resolve environment variable: SUPABASE_AUTH_SERVICE_ROLE_KEY", - }), - ), + const shell = yield* readShellEnvironment({ + names: [CliEnvNames.authServiceRoleKey.name], + }).pipe(Effect.mapError(toStorageConfigError)); + const envKey = yield* Result.match( + pickCliEnvName(CliEnvNames.authServiceRoleKey, { shell: shell.get }), + { + onFailure: (error) => Effect.fail(toStorageConfigError(error)), + onSuccess: Effect.succeed, + }, ); - if (Option.isSome(envKey) && envKey.value.length > 0) { - return { baseUrl, apiKey: envKey.value, localKongCa: undefined } satisfies StorageCredentials; + if (Option.isSome(envKey)) { + return { + baseUrl, + apiKey: envKey.value, + localKongCa: undefined, + } satisfies StorageCredentials; } // Resolved lazily so the local path never triggers auth. const api = yield* (yield* CommandPlatformApiFactory).make; @@ -130,19 +126,15 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const projectEnvValues = - opts.projectEnvValues ?? - (yield* loadProjectEnv(fs, path, cliSettings.workdir).pipe( - Effect.mapError((cause) => new StorageConfigError({ message: cause.message })), - )); - const api = yield* resolveLocalApiConfig(opts.config.api, projectEnvValues); + const { config, resolvedConfig } = yield* loadLocalStorageConfig(cliSettings.workdir); + const api = yield* resolveLocalApiConfig(config.api); const baseUrl = resolveApiExternalUrl( api, - yield* getHostname(projectEnvValues).pipe( + yield* getHostname(resolvedConfig).pipe( Effect.mapError((cause) => new StorageConfigError({ message: cause.message })), ), ); - const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth, projectEnvValues); + const apiKey = yield* resolveLocalServiceRoleKey(config.auth); // Validate the cert/key pairing only when the API and TLS are both enabled; // inject a CA whenever the resolved URL is https. @@ -164,8 +156,7 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { }); /** - * Converts a thrown config-load validation error (from `envOverride*`, - * `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) into a tagged + * Converts a thrown config-load validation error (from `resolveJwtSecret`, `validateApi*`) into a tagged * `StorageConfigError`, preserving the original message. */ const toStorageConfigError = (cause: unknown) => @@ -173,90 +164,50 @@ const toStorageConfigError = (cause: unknown) => message: cause instanceof Error ? cause.message : String(cause), }); -/** - * Folds `SUPABASE_API_*` overrides into `[api]` before deriving the gateway - * URL, so a stack started with an overridden port stays reachable here - * instead of falling back to the raw `config.toml` value. - */ -const resolveLocalApiConfig = ( - api: StorageConfigView["api"], - projectEnvValues: Readonly>, -) => +const loadLocalStorageConfig = (workdir: string) => + loadResolvedConfigContext(workdir).pipe( + Effect.mapError( + (cause) => new StorageConfigError({ message: describeConfigLoadFailure(cause) }), + ), + ); + +/** The effective `[api]` view, with the port and TLS pairing checked, for deriving the gateway URL. */ +const resolveLocalApiConfig = (api: StorageConfigView["api"]) => Effect.try({ try: () => { - const resolved = { - enabled: envOverrideBool( - "SUPABASE_API_ENABLED", - api.enabled, - "api.enabled", - projectEnvValues, - ), - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, projectEnvValues), - port: envOverridePort("SUPABASE_API_PORT", api.port, "api.port", projectEnvValues), - tls: { - enabled: envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, projectEnvValues), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, projectEnvValues), - }, - } satisfies StorageConfigView["api"]; - validateApiPort(resolved.enabled, resolved.port); - return resolved; + validateApiPort(api.enabled, api.port); + return api; }, catch: toStorageConfigError, }); /** * Resolves the service-role key for the local Storage gateway: - * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` → `auth.jwt_secret` → - * `defaultJwtSecret`, rejected if shorter than 16 chars. - * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` → - * `auth.service_role_key` → signed from the resolved jwt secret. + * - jwt secret: `auth.jwt_secret` → `defaultJwtSecret`, rejected if shorter than 16 chars. + * - service-role key: `auth.service_role_key` → signed from the resolved jwt secret. * * An explicit `service_role_key = ""` is treated as unset and regenerated. */ -const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( - auth: StorageConfigView["auth"], - projectEnvValues: Readonly>, -) { +const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: StorageConfigView["auth"]) { const jwtSecret = yield* Effect.try({ - try: () => - resolveJwtSecret( - decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ), - catch: toStorageConfigError, - }); - const configuredKey = yield* Effect.try({ - try: () => - decryptAuthSecret( - envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, projectEnvValues), - projectEnvValues, - ), + try: () => resolveJwtSecret(auth.jwt_secret), catch: toStorageConfigError, }); + const configuredKey = auth.service_role_key; return configuredKey !== undefined && configuredKey.length > 0 ? configuredKey - : generateGoJwt(jwtSecret, "service_role"); + : generateLocalJwt(jwtSecret, "service_role"); }); /** - * Runs the local config-load validations (API overrides, auth secret - * decryption, TLS presence) without building credentials, for `seed - * buckets`'s empty-config short-circuit. + * Runs the local config-load validations (API port, auth secrets, TLS presence) without building + * credentials, for `seed buckets`'s empty-config short-circuit. */ -export const validateLocalStorageConfig = Effect.fnUntraced(function* ( - config: StorageConfigView, - projectEnvValues: Readonly>, -) { - const api = yield* resolveLocalApiConfig(config.api, projectEnvValues); - yield* resolveLocalServiceRoleKey(config.auth, projectEnvValues); +export const validateLocalStorageConfig = Effect.fnUntraced(function* () { + const cliSettings = yield* CommandSettings; + const { config } = yield* loadLocalStorageConfig(cliSettings.workdir); + const api = yield* resolveLocalApiConfig(config.api); + yield* resolveLocalServiceRoleKey(config.auth); if (api.enabled && api.tls.enabled) { yield* Effect.try({ try: () => validateApiTlsPresence(api.tls.cert_path, api.tls.key_path), diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 3e20d9cca9..6190f17106 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -4,9 +4,11 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { announceTarget } from "../shared/output/command-target.service.ts"; import { kongAuthHeaders } from "./kong-auth.ts"; import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage-gateway.errors.ts"; -import { goPathSplit } from "./storage-url.ts"; +import { splitDirAndFile } from "./storage-url.ts"; +import { renderLocalTarget } from "./target/target-identity.ts"; /** * Client for the Supabase Storage service gateway (Kong). See @@ -149,6 +151,15 @@ function localGatewayHint(port: string): string { ); } +/** Announces the local Storage API endpoint as the run's target. */ +export const announceLocalStorageTarget = (baseUrl: string) => { + const { hostname, port, protocol } = new URL(baseUrl); + const defaultPort = protocol === "https:" ? 443 : 80; + return announceTarget( + renderLocalTarget(hostname.replace(/^\[|\]$/g, ""), port === "" ? defaultPort : Number(port)), + ); +}; + /** * Whether a transport failure is a plain connection-refused. The * port-conflict hint is suppressed for these, since a refused connection @@ -420,7 +431,7 @@ export const makeStorageGateway = Effect.fnUntraced(function* (opts: { Effect.flatMap((body) => decodeFieldResponse(body, "message")), ), listObjects: (bucket, prefix, page) => { - const [dir, name] = goPathSplit(prefix); + const [dir, name] = splitDirAndFile(prefix); const query: Record = { prefix: dir }; if (name.length > 0) query["search"] = name; query["limit"] = PAGE_LIMIT; diff --git a/apps/cli/src/command-internal/storage-gateway.unit.test.ts b/apps/cli/src/command-internal/storage-gateway.unit.test.ts index ae4aa51155..0ae48a9be2 100644 --- a/apps/cli/src/command-internal/storage-gateway.unit.test.ts +++ b/apps/cli/src/command-internal/storage-gateway.unit.test.ts @@ -19,7 +19,7 @@ import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage import { bucketBody, makeStorageGateway } from "./storage-gateway.ts"; describe("bucketBody", () => { - it("omits public when undefined (Go *bool nil / omitempty)", () => { + it("omits public when undefined", () => { expect(bucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({}); }); diff --git a/apps/cli/src/command-internal/storage-permissions.ts b/apps/cli/src/command-internal/storage-permissions.ts new file mode 100644 index 0000000000..0391e20b4c --- /dev/null +++ b/apps/cli/src/command-internal/storage-permissions.ts @@ -0,0 +1,12 @@ +import { linkedProjectCache } from "../telemetry/linked-project-cache.layer.ts"; +import { compose, withCondition } from "./command-permissions/model.ts"; +import { storageCredentials } from "./storage-credentials.ts"; +import { branchResolution } from "./target/branch-resolution.ts"; + +/** The Management API calls a Storage-API command makes on the linked project. */ +export const storagePermissions = (noApiEffectFlags: ReadonlyArray) => + compose( + { operations: [], noApiEffectFlags }, + withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + branchResolution, + ); diff --git a/apps/cli/src/command-internal/storage-runtime.layer.ts b/apps/cli/src/command-internal/storage-runtime.layer.ts index c21642f664..46518fbfdf 100644 --- a/apps/cli/src/command-internal/storage-runtime.layer.ts +++ b/apps/cli/src/command-internal/storage-runtime.layer.ts @@ -6,8 +6,8 @@ import { commandPlatformApiFactoryLayer } from "../auth/command-platform-api-fac import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { remoteTargetLayer } from "../config/remote-target.layer.ts"; -import { RemoteTarget } from "../config/remote-target.service.ts"; +import { projectRefLayer } from "../config/project-ref.layer.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { IdentityStitch, identityStitchLayer } from "./identity-stitch.ts"; import { httpClientLayer } from "../auth/http-debug.layer.ts"; @@ -22,7 +22,7 @@ import { StackApi, stackApiLayer } from "./stack-api.ts"; /** * Runtime layer for the commands that talk to the Storage gateway directly: * `seed buckets` and `storage ls/cp/mv/rm`. The Management API client is lazy, - * so the local path (no `--linked`) never resolves a token or requires login. + * so the local default (no `--remote`) never resolves a token or requires login. * * `HttpClient` is exposed at the top level because the Storage gateway needs * it directly, not through the typed Management API client. @@ -35,7 +35,7 @@ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray) { Layer.provide(debugLoggerLayer), ); // Token resolution is deferred until `factory.make` is first called, on the - // `--linked` branch. + // hosted-target branch. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -47,7 +47,7 @@ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray) { cliSettings, platformApiFactory, httpClient, - remoteTargetLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), + projectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), linkedProjectCacheLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -74,7 +74,7 @@ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray) { type StorageGatewayServices = | CommandPlatformApiFactory | CommandSettings - | RemoteTarget + | ProjectRefResolver | LinkedProjectCache | TelemetryState | IdentityStitch diff --git a/apps/cli/src/command-internal/storage-url.ts b/apps/cli/src/command-internal/storage-url.ts index be2c7ddbbd..b77a3bfde4 100644 --- a/apps/cli/src/command-internal/storage-url.ts +++ b/apps/cli/src/command-internal/storage-url.ts @@ -8,8 +8,8 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Storage URL parsing, matching Go's `net/url.Parse` semantics for the `ss://` - * scheme. `goUrlParse` implements `net/url.Parse` for the fields storage + * Storage URL parsing for the `ss://` + * scheme. `parseUrl` parses the fields storage * commands need (scheme, host, path); `parseStorageUrl` additionally requires * scheme `ss` (case-insensitive), a non-empty path, and no host. * @@ -22,10 +22,11 @@ export const STORAGE_SCHEME = "ss"; const STORAGE_INVALID_URL_MESSAGE = "URL must match pattern ss:///bucket/[prefix]"; /** - * Thrown when `goUrlParse` fails: `parse "": `. Callers wrap + * Thrown when `parseUrl` fails: `parse "": `. Callers wrap * `.message` in their own `failed to parse … url: ` text. */ -export class GoUrlParseError extends Data.Error<{ readonly message: string }> { +export class UrlParseError extends Data.Error<{ readonly message: string }> { + // The fingerprint and `name` are the stable telemetry/output identity of this error. static readonly [ErrorActionabilityFingerprintId] = "GoUrlParseError"; override readonly name = "GoUrlParseError"; constructor(rawURL: string, inner: string) { @@ -39,7 +40,7 @@ export class GoUrlParseError extends Data.Error<{ readonly message: string }> { /** * Thrown when a URL parses but does not match the `ss:///bucket/[prefix]` - * pattern. Distinct from `GoUrlParseError` so handlers can map it to a + * pattern. Distinct from `UrlParseError` so handlers can map it to a * separate tagged error. */ export class StorageUrlPatternError extends Data.Error<{ readonly message: string }> { @@ -54,7 +55,7 @@ export class StorageUrlPatternError extends Data.Error<{ readonly message: strin } } -export interface GoUrl { +export interface ParsedUrl { /** Lowercased scheme; `""` when none. */ readonly scheme: string; /** Authority host (after `//`); `""` when absent. */ @@ -168,12 +169,12 @@ function isValidOptionalPort(port: string): boolean { /** * Validates IP-literal (`[...]`) bracket/port syntax and `host[:port]` - * port-digit checks, matching Go's host validation for realistic inputs but + * port-digit checks, for realistic inputs but * skipping bracketed-literal IP-address checks and IPv6 zone-id decoding. * http/https treat the first colon as the port separator; other schemes use * the last. */ -function validateGoUrlHost(scheme: string, host: string): void { +function validateUrlHost(scheme: string, host: string): void { if (host.length === 0) return; const openBracketIdx = host.indexOf("["); if (openBracketIdx > 0) { @@ -201,16 +202,16 @@ function validateGoUrlHost(scheme: string, host: string): void { } /** - * Parses a URL for `scheme`/`host`/`path`, matching Go's `net/url.Parse`. - * Throws `GoUrlParseError` on parse failure. Query and fragment are stripped, + * Parses a URL for `scheme`/`host`/`path`. + * Throws `UrlParseError` on parse failure. Query and fragment are stripped, * though storage URLs never use them. */ -export function goUrlParse(rawURL: string): GoUrl { +export function parseUrl(rawURL: string): ParsedUrl { const hashIdx = rawURL.indexOf("#"); const u = hashIdx === -1 ? rawURL : rawURL.slice(0, hashIdx); if (containsCtlByte(u)) { - throw new GoUrlParseError(u, "net/url: invalid control character in URL"); + throw new UrlParseError(u, "net/url: invalid control character in URL"); } if (u === "*") { return { scheme: "", host: "", path: "*" }; @@ -223,7 +224,7 @@ export function goUrlParse(rawURL: string): GoUrl { scheme = parsed.scheme.toLowerCase(); rest = parsed.rest; } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } // Strip the query string. @@ -239,7 +240,7 @@ export function goUrlParse(rawURL: string): GoUrl { const slash = rest.indexOf("/"); const segment = slash === -1 ? rest : rest.slice(0, slash); if (segment.includes(":")) { - throw new GoUrlParseError(u, "first path segment in URL cannot contain colon"); + throw new UrlParseError(u, "first path segment in URL cannot contain colon"); } } @@ -253,9 +254,9 @@ export function goUrlParse(rawURL: string): GoUrl { rest = slash === -1 ? "" : afterSlashes.slice(slash); host = hostFromAuthority(authority); try { - validateGoUrlHost(scheme, host); + validateUrlHost(scheme, host); } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } } @@ -263,7 +264,7 @@ export function goUrlParse(rawURL: string): GoUrl { try { path = unescapePath(rest); } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } return { scheme, host, path }; } @@ -271,11 +272,11 @@ export function goUrlParse(rawURL: string): GoUrl { /** * Parses a storage URL, requiring scheme `ss` (case-insensitive), a * non-empty path, and no host, and returns the path. Throws - * `GoUrlParseError` on a parse failure, or `StorageUrlPatternError` when it + * `UrlParseError` on a parse failure, or `StorageUrlPatternError` when it * doesn't match `ss:///bucket/[prefix]`. */ export function parseStorageUrl(objectURL: string): string { - const parsed = goUrlParse(objectURL); + const parsed = parseUrl(objectURL); if (parsed.scheme !== STORAGE_SCHEME || parsed.path.length === 0 || parsed.host.length > 0) { throw new StorageUrlPatternError(); } @@ -301,10 +302,10 @@ export function splitBucketPrefix(objectPath: string): readonly [string, string] /** * Lowercased scheme for a raw URL; `""` when the URL has no scheme (a local - * path). Throws `GoUrlParseError` on a malformed URL (e.g. `:`). + * path). Throws `UrlParseError` on a malformed URL (e.g. `:`). */ export function detectScheme(rawURL: string): string { - return goUrlParse(rawURL).scheme; + return parseUrl(rawURL).scheme; } /** An object prefix is a directory when it is empty or ends with `/`. */ @@ -317,7 +318,7 @@ export function storageIsDir(objectPrefix: string): boolean { * trailing slash: `folder/name.png` → `["folder/", "name.png"]`; `dir` → * `["", "dir"]`; `tmp/` → `["tmp/", ""]`; `""` → `["", ""]`. */ -export function goPathSplit(p: string): readonly [string, string] { +export function splitDirAndFile(p: string): readonly [string, string] { const i = p.lastIndexOf("/"); return [p.slice(0, i + 1), p.slice(i + 1)]; } diff --git a/apps/cli/src/command-internal/storage-url.unit.test.ts b/apps/cli/src/command-internal/storage-url.unit.test.ts index 49ea25990f..fd33e21af2 100644 --- a/apps/cli/src/command-internal/storage-url.unit.test.ts +++ b/apps/cli/src/command-internal/storage-url.unit.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; import { - GoUrlParseError, + UrlParseError, StorageUrlPatternError, detectScheme, - goUrlParse, + parseUrl, parseStorageUrl, splitBucketPrefix, storageIsDir, @@ -32,7 +32,7 @@ describe("parseStorageUrl", () => { }); it("surfaces the url-parse error on a missing protocol scheme (:)", () => { - expect(() => parseStorageUrl(":")).toThrow(GoUrlParseError); + expect(() => parseStorageUrl(":")).toThrow(UrlParseError); expect(() => parseStorageUrl(":")).toThrow("missing protocol scheme"); }); @@ -41,7 +41,7 @@ describe("parseStorageUrl", () => { }); it("rejects a malformed percent-escape in the path", () => { - expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow(GoUrlParseError); + expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow(UrlParseError); expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow('invalid URL escape "%2"'); }); @@ -49,8 +49,8 @@ describe("parseStorageUrl", () => { expect(parseStorageUrl("ss:///bucket/a%20b")).toBe("/bucket/a b"); }); - it("decodes a multi-byte UTF-8 percent-escape as one rune (Go raw-byte path)", () => { - // %E4%B8%AD is the UTF-8 encoding of 中 — decoded as one rune, not three. + it("decodes a multi-byte UTF-8 percent-escape as one code point", () => { + // %E4%B8%AD is the UTF-8 encoding of 中 — decoded as one code point, not three. expect(parseStorageUrl("ss:///bucket/%E4%B8%AD.txt")).toBe("/bucket/中.txt"); }); }); @@ -86,50 +86,50 @@ describe("detectScheme", () => { }); // Host validation here also backs `studio.api_url` validation. -describe("goUrlParse (host validation)", () => { +describe("parseUrl (host validation)", () => { it("rejects an unterminated IPv6 literal", () => { - expect(() => goUrlParse("http://[::1")).toThrow(GoUrlParseError); - expect(() => goUrlParse("http://[::1")).toThrow(`parse "http://[::1": missing ']' in host`); + expect(() => parseUrl("http://[::1")).toThrow(UrlParseError); + expect(() => parseUrl("http://[::1")).toThrow(`parse "http://[::1": missing ']' in host`); }); it("accepts a bracketed IPv6 literal with no port", () => { - expect(goUrlParse("http://[::1]").host).toBe("[::1]"); + expect(parseUrl("http://[::1]").host).toBe("[::1]"); }); it("accepts a bracketed IPv6 literal with a valid port", () => { - expect(goUrlParse("http://[::1]:8080").host).toBe("[::1]:8080"); + expect(parseUrl("http://[::1]:8080").host).toBe("[::1]:8080"); }); it("rejects a bracketed IPv6 literal with a non-numeric port", () => { - expect(() => goUrlParse("http://[::1]:abc")).toThrow( + expect(() => parseUrl("http://[::1]:abc")).toThrow( `parse "http://[::1]:abc": invalid port ":abc" after host`, ); }); it("rejects a bracket that isn't the first character of the host", () => { - expect(() => goUrlParse("http://host[::1]")).toThrow( + expect(() => parseUrl("http://host[::1]")).toThrow( `parse "http://host[::1]": invalid IP-literal`, ); }); it("accepts a plain hostname with a numeric port", () => { - expect(goUrlParse("http://example.com:99999").host).toBe("example.com:99999"); + expect(parseUrl("http://example.com:99999").host).toBe("example.com:99999"); }); it("rejects more than one colon in an http(s) host (strict-colon default)", () => { - expect(() => goUrlParse("http://host:1:2")).toThrow( + expect(() => parseUrl("http://host:1:2")).toThrow( `parse "http://host:1:2": invalid port ":1:2" after host`, ); }); it("does not validate a host for a scheme with no authority (ss:///bucket)", () => { - expect(() => goUrlParse("ss:///bucket/x")).not.toThrow(); + expect(() => parseUrl("ss:///bucket/x")).not.toThrow(); }); }); describe("storage URL errors", () => { it("keep their names and telemetry fingerprints", () => { - const parseError = new GoUrlParseError(":", "missing protocol scheme"); + const parseError = new UrlParseError(":", "missing protocol scheme"); expect(String(parseError)).toBe('GoUrlParseError: parse ":": missing protocol scheme'); expect(classifyCliErrorActionability(parseError)).toMatchObject({ error_fingerprint: "error:GoUrlParseError", diff --git a/apps/cli/src/command-internal/string-slice-flag.ts b/apps/cli/src/command-internal/string-slice-flag.ts index 26b91b3e85..d56efae30a 100644 --- a/apps/cli/src/command-internal/string-slice-flag.ts +++ b/apps/cli/src/command-internal/string-slice-flag.ts @@ -6,8 +6,8 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence, matching - * `encoding/csv`, and accumulates across repeats. A naive `.split(",")` + * Parses a repeatable string-slice flag: CSV-splits each occurrence + * and accumulates across repeats. A naive `.split(",")` * diverges on quoted/embedded commas (e.g. `'"a,b",c'`). * * Whitespace is not trimmed and empty fields are not dropped. @@ -66,8 +66,7 @@ export class StringSliceFlagParseError extends Data.Error<{ } /** - * Parses one CSV record from `val`, matching Go's `encoding/csv` reader as - * used by pflag's `readAsCSV`. Only the first record is read — an unquoted + * Parses one CSV record from `val`. Only the first record is read — an unquoted * newline ends it and the rest is dropped. Blank lines before the record are * skipped; blank-only input throws `EOF`. `\r\n` normalizes to `\n`, and * parse errors report 1-based line/byte-column positions. @@ -244,10 +243,10 @@ export function parseStringSliceFlag(rawValues: ReadonlyArray): Readonly } /** - * Builds a repeatable CSV-split flag matching pflag's `StringSliceVar` - * behavior, including its `invalid argument %q for %q flag: %v` diagnostic. + * Builds a repeatable CSV-split flag, including its `invalid argument "" for "" flag: ` + * diagnostic. * - * `options.alias` must be registered here, not piped on afterwards: pflag's + * `options.alias` must be registered here, not piped on afterwards: the * diagnostic frames both spellings (`-x, --exclude`), so the alias must be * present when the error message is built. */ @@ -257,14 +256,14 @@ export function stringSliceFlag( options?: { readonly alias?: string }, ) { const alias = options?.alias; - const pflagName = alias === undefined ? `--${name}` : `-${alias}, --${name}`; + const flagLabel = alias === undefined ? `--${name}` : `-${alias}, --${name}`; const base = Flag.String(name).pipe(Flag.withDescription(description), Flag.atLeast(0)); return (alias === undefined ? base : base.pipe(Flag.withAlias(alias))).pipe( Flag.mapTryCatch( (rawValues) => parseStringSliceFlag(rawValues), (err) => err instanceof StringSliceFlagParseError - ? `invalid argument ${JSON.stringify(err.value)} for "${pflagName}" flag: ${err.message}` + ? `invalid argument ${JSON.stringify(err.value)} for "${flagLabel}" flag: ${err.message}` : err instanceof Error ? err.message : String(err), diff --git a/apps/cli/src/command-internal/string-slice-flag.unit.test.ts b/apps/cli/src/command-internal/string-slice-flag.unit.test.ts index 7d70f88533..438e1bf775 100644 --- a/apps/cli/src/command-internal/string-slice-flag.unit.test.ts +++ b/apps/cli/src/command-internal/string-slice-flag.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseStringSliceFlag, StringSliceFlagParseError } from "./string-slice-flag.ts"; -describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { +describe("parseStringSliceFlag", () => { it("splits unquoted comma-separated values", () => { expect(parseStringSliceFlag(["public,private"])).toEqual(["public", "private"]); }); @@ -34,13 +34,13 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(parseStringSliceFlag([])).toEqual([]); }); - it("preserves whitespace (Go does not trim)", () => { + it("preserves whitespace (no trimming)", () => { expect(parseStringSliceFlag([" public , private "])).toEqual([" public ", " private "]); }); // Columns are 1-based byte offsets. - it("throws on an unterminated quoted field (column = byte length + 1, Go hits EOF)", () => { + it("throws on an unterminated quoted field (column = byte length + 1, reached EOF)", () => { expect(() => parseStringSliceFlag(['"tenant'])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(['"tenant'])).toThrow( 'parse error on line 1, column 8: extraneous or missing " in quoted-field', @@ -74,7 +74,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { ); }); - it("counts columns in bytes, not code points (Go csv tracks byte offsets)", () => { + it("counts columns in bytes, not code points", () => { // é is 2 UTF-8 bytes, so the bare quote is at byte 3. expect(() => parseStringSliceFlag(['é"x'])).toThrow( 'parse error on line 1, column 3: bare " in non-quoted-field', @@ -85,7 +85,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { ); }); - it("carries the offending occurrence and Go's exact message on the error for pflag framing", () => { + it("carries the offending occurrence and the exact message on the error for flag framing", () => { try { parseStringSliceFlag(["public", '"broken']); expect.unreachable("expected parseStringSliceFlag to throw"); @@ -106,7 +106,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { // Only the first CSV record survives; blank lines before it are skipped, // `\r\n` normalizes to `\n`, and parse errors report per-line byte columns. - it("keeps only the first record when an unquoted newline ends it (pflag reads ONE record)", () => { + it("keeps only the first record when an unquoted newline ends it (only ONE record is read)", () => { expect(parseStringSliceFlag(["1.2.3.4\n5.6.7.8"])).toEqual(["1.2.3.4"]); // The dropped remainder is itself malformed CSV, but only the first record is parsed. expect(parseStringSliceFlag(['1.2.3.4\na"b'])).toEqual(["1.2.3.4"]); @@ -116,7 +116,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(parseStringSliceFlag(["a\r\nb"])).toEqual(["a"]); }); - it("skips blank lines before the record and errors with pflag's EOF for blank-only values", () => { + it("skips blank lines before the record and errors with EOF for blank-only values", () => { expect(parseStringSliceFlag(["\n\na"])).toEqual(["a"]); expect(parseStringSliceFlag(["\r\na"])).toEqual(["a"]); // A value of only blank lines throws the bare `EOF` message. diff --git a/apps/cli/src/command-internal/rune-width.ts b/apps/cli/src/command-internal/string-width.ts similarity index 98% rename from apps/cli/src/command-internal/rune-width.ts rename to apps/cli/src/command-internal/string-width.ts index 110dbb7fb8..ac73acf2c2 100644 --- a/apps/cli/src/command-internal/rune-width.ts +++ b/apps/cli/src/command-internal/string-width.ts @@ -376,7 +376,7 @@ function inRanges(cp: number, ranges: ReadonlyArray): boolean { } /** Display width of a single code point (0, 1, or 2). */ -function runeWidth(cp: number): number { +function codePointWidth(cp: number): number { // C0/C1 controls (except those handled by the caller) have no print width. if (cp === 0) return 0; if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; @@ -388,6 +388,6 @@ function runeWidth(cp: number): number { /** Display width of a string, summing per-code-point widths. */ export function stringWidth(text: string): number { let width = 0; - for (const ch of text) width += runeWidth(ch.codePointAt(0)!); + for (const ch of text) width += codePointWidth(ch.codePointAt(0)!); return width; } diff --git a/apps/cli/src/command-internal/rune-width.unit.test.ts b/apps/cli/src/command-internal/string-width.unit.test.ts similarity index 88% rename from apps/cli/src/command-internal/rune-width.unit.test.ts rename to apps/cli/src/command-internal/string-width.unit.test.ts index 92d4cb21f0..44ea607d25 100644 --- a/apps/cli/src/command-internal/rune-width.unit.test.ts +++ b/apps/cli/src/command-internal/string-width.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { stringWidth } from "./rune-width.ts"; +import { stringWidth } from "./string-width.ts"; describe("stringWidth", () => { it("counts ASCII as 1 each", () => { @@ -25,7 +25,7 @@ describe("stringWidth", () => { }); it("treats East Asian Ambiguous as width 1 (modern-terminal default)", () => { - // U+00A1 (¡) is Ambiguous; Go's runewidth with EastAsianWidth=false counts it as 1. + // U+00A1 (¡) is Ambiguous and counts as 1. expect(stringWidth("¡")).toBe(1); }); }); diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.ts b/apps/cli/src/command-internal/struct-output.encoders.ts similarity index 74% rename from apps/cli/src/command-internal/go-struct-output.encoders.ts rename to apps/cli/src/command-internal/struct-output.encoders.ts index 554d655de7..9d193eca49 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.ts +++ b/apps/cli/src/command-internal/struct-output.encoders.ts @@ -8,76 +8,75 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Byte-faithful `-o yaml`/`-o toml` output for struct payloads, matching `gopkg.in/yaml.v3` and - * `github.com/BurntSushi/toml`. Neither library reads `json:` tags, so emitted keys are the - * original Go field names, not the snake_case JSON the API returns. + * Byte-stable `-o yaml`/`-o toml` output for struct payloads. Emitted keys are the + * PascalCase field names declared by the shape, not the snake_case JSON the API returns. * - * Each payload family declares a {@link GoType} spec mirroring the original struct so the decoded - * JSON can be re-expressed with each library's own casing, nil handling, and quoting rules. + * Each payload family declares a {@link OutputShape} spec describing the struct so the decoded + * JSON can be re-expressed with the established casing, null handling, and quoting rules. */ -export type GoType = +export type OutputShape = | { readonly kind: "string" } | { readonly kind: "uuid" } | { readonly kind: "bool" } | { readonly kind: "int" } | { readonly kind: "float"; readonly bits: 32 | 64 } - /** Go `time.Time` — native TOML datetime, unquoted yaml timestamp. */ + /** RFC3339 timestamp — native TOML datetime, unquoted yaml timestamp. */ | { readonly kind: "time" } - /** Go `interface{}` — shape inferred from the JSON value like `encoding/json` decoding. */ + /** Untyped value — shape inferred from the JSON value. */ | { readonly kind: "any" } - | { readonly kind: "ptr"; readonly elem: GoType } - /** oapi-codegen `nullable.Nullable[T]` — a `map[bool]T` under the hood. */ - | { readonly kind: "nullable"; readonly elem: GoType } - | { readonly kind: "slice"; readonly elem: GoType } - | { readonly kind: "map"; readonly value: GoType } - | { readonly kind: "struct"; readonly fields: ReadonlyArray }; - -interface GoStructField { + | { readonly kind: "ptr"; readonly elem: OutputShape } + /** A nullable API field: absent, explicit null, or a value. */ + | { readonly kind: "nullable"; readonly elem: OutputShape } + | { readonly kind: "slice"; readonly elem: OutputShape } + | { readonly kind: "map"; readonly value: OutputShape } + | { readonly kind: "struct"; readonly fields: ReadonlyArray }; + +interface OutputShapeField { /** JSON tag name — the key present in the decoded payload. */ readonly json: string; - /** Go field name (PascalCase). */ - readonly go: string; - readonly type: GoType; + /** Struct field name (PascalCase). */ + readonly name: string; + readonly type: OutputShape; } -export const goString: GoType = { kind: "string" }; -export const goUuid: GoType = { kind: "uuid" }; -export const goBool: GoType = { kind: "bool" }; -export const goInt: GoType = { kind: "int" }; -export const goFloat32: GoType = { kind: "float", bits: 32 }; -export const goFloat64: GoType = { kind: "float", bits: 64 }; -export const goTime: GoType = { kind: "time" }; -export const goAny: GoType = { kind: "any" }; +export const shapeString: OutputShape = { kind: "string" }; +export const shapeUuid: OutputShape = { kind: "uuid" }; +export const shapeBool: OutputShape = { kind: "bool" }; +export const shapeInt: OutputShape = { kind: "int" }; +export const shapeFloat32: OutputShape = { kind: "float", bits: 32 }; +export const shapeFloat64: OutputShape = { kind: "float", bits: 64 }; +export const shapeTime: OutputShape = { kind: "time" }; +export const shapeAny: OutputShape = { kind: "any" }; -export function goPtr(elem: GoType): GoType { +export function shapePtr(elem: OutputShape): OutputShape { return { kind: "ptr", elem }; } -export function goNullable(elem: GoType): GoType { +export function shapeNullable(elem: OutputShape): OutputShape { return { kind: "nullable", elem }; } -export function goSlice(elem: GoType): GoType { +export function shapeSlice(elem: OutputShape): OutputShape { return { kind: "slice", elem }; } -export function goMap(value: GoType): GoType { +export function shapeMap(value: OutputShape): OutputShape { return { kind: "map", value }; } /** - * A struct field spec entry: `[jsonName, type]` derives the Go field name mechanically (each - * snake_case token capitalized: `api_key` → `ApiKey`), or `[jsonName, type, goName]` for explicit + * A struct field spec entry: `[jsonName, type]` derives the field name mechanically (each + * snake_case token capitalized: `api_key` → `ApiKey`), or `[jsonName, type, fieldName]` for explicit * names. */ -export type GoFieldSpec = - | readonly [json: string, type: GoType] - | readonly [json: string, type: GoType, goName: string]; +export type OutputShapeFieldSpec = + | readonly [json: string, type: OutputShape] + | readonly [json: string, type: OutputShape, fieldName: string]; -export function goStruct(fields: ReadonlyArray): GoType { +export function shapeStruct(fields: ReadonlyArray): OutputShape { return { kind: "struct", - fields: fields.map(([json, type, goName]) => ({ + fields: fields.map(([json, type, fieldName]) => ({ json, - go: goName ?? goFieldName(json), + name: fieldName ?? pascalCaseFieldName(json), type, })), }; @@ -85,57 +84,61 @@ export function goStruct(fields: ReadonlyArray): GoType { /** * The anonymous wrapper struct list commands use for TOML output (a `toml:` tag keeps the wrapper - * key lowercase while the elements keep Go field names), and also models a single-key map wrapper + * key lowercase while the elements keep PascalCase field names), and also models a single-key map wrapper * (`sso list`), since a one-field lowercase-keyed struct renders identically to a one-key map in * both encoders. */ -export function goTomlListWrapper(key: string, elem: GoType): GoType { - return { kind: "struct", fields: [{ json: key, go: key, type: goSlice(elem) }] }; +export function shapeTomlListWrapper(key: string, elem: OutputShape): OutputShape { + return { kind: "struct", fields: [{ json: key, name: key, type: shapeSlice(elem) }] }; } /** `api_key` → `ApiKey`, `dbAllowedCidrs` → `DbAllowedCidrs`. */ -export function goFieldName(jsonName: string): string { +export function pascalCaseFieldName(jsonName: string): string { return jsonName .split("_") .map((part) => (part.length === 0 ? part : part[0]?.toUpperCase() + part.slice(1))) .join(""); } -type GoValue = +type NormalizedValue = | { readonly k: "nil" } | { readonly k: "str"; readonly v: string } | { readonly k: "bool"; readonly v: boolean } | { readonly k: "int"; readonly v: number } | { readonly k: "float"; readonly v: number; readonly bits: 32 | 64 } | { readonly k: "time"; readonly v: string } - | { readonly k: "struct"; readonly entries: ReadonlyArray } + | { readonly k: "struct"; readonly entries: ReadonlyArray } | { readonly k: "map"; readonly nil: boolean; - readonly entries: ReadonlyArray; + readonly entries: ReadonlyArray; + } + /** A nullable field: absent (nil), `{false: zero}` (explicit null) or `{true: value}`. */ + | { + readonly k: "nullable"; + readonly present: boolean | undefined; + readonly value?: NormalizedValue; } - /** `nullable.Nullable[T]`: nil map, `{false: zero}` (explicit null) or `{true: value}`. */ - | { readonly k: "nullable"; readonly present: boolean | undefined; readonly value?: GoValue } | { readonly k: "slice"; readonly nil: boolean; - readonly items: ReadonlyArray; + readonly items: ReadonlyArray; readonly tables: boolean; }; -const GO_ZERO_TIME = "0001-01-01T00:00:00Z"; -const GO_ZERO_UUID = "00000000-0000-0000-0000-000000000000"; +const ZERO_TIME = "0001-01-01T00:00:00Z"; +const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function zeroValue(type: GoType): GoValue { +function zeroValue(type: OutputShape): NormalizedValue { switch (type.kind) { case "string": return { k: "str", v: "" }; case "uuid": - return { k: "str", v: GO_ZERO_UUID }; + return { k: "str", v: ZERO_UUID }; case "bool": return { k: "bool", v: false }; case "int": @@ -143,7 +146,7 @@ function zeroValue(type: GoType): GoValue { case "float": return { k: "float", v: 0, bits: type.bits }; case "time": - return { k: "time", v: GO_ZERO_TIME }; + return { k: "time", v: ZERO_TIME }; case "struct": return normalize(undefined, type); case "ptr": @@ -158,7 +161,7 @@ function zeroValue(type: GoType): GoValue { } } -function elementsAreTables(elem: GoType, items: ReadonlyArray): boolean { +function elementsAreTables(elem: OutputShape, items: ReadonlyArray): boolean { switch (elem.kind) { case "struct": case "map": @@ -168,15 +171,14 @@ function elementsAreTables(elem: GoType, items: ReadonlyArray): boolean case "slice": return elem.kind === "ptr" ? elementsAreTables(elem.elem, items) : false; case "any": - // Like Go's runtime type inspection: JSON objects decode to - // map[string]interface{} which BurntSushi treats as tables. + // JSON objects decode to maps, which are rendered as tables. return items.length > 0 && items.every(isRecord); default: return false; } } -function normalize(value: unknown, type: GoType): GoValue { +function normalize(value: unknown, type: OutputShape): NormalizedValue { switch (type.kind) { case "string": return typeof value === "string" ? { k: "str", v: value } : zeroValue(type); @@ -196,13 +198,12 @@ function normalize(value: unknown, type: GoType): GoValue { : zeroValue(type); case "time": return typeof value === "string" && value.length > 0 - ? { k: "time", v: normalizeGoTime(value) } + ? { k: "time", v: normalizeTime(value) } : zeroValue(type); case "ptr": return value === undefined || value === null ? { k: "nil" } : normalize(value, type.elem); case "nullable": - // oapi-codegen: absent key → nil map; explicit JSON null → {false: zero}; - // value → {true: value}. + // Absent key → nil; explicit JSON null → {false: zero}; value → {true: value}. if (value === undefined) return { k: "nullable", present: undefined }; if (value === null) return { k: "nullable", present: false, value: zeroValue(type.elem) }; return { k: "nullable", present: true, value: normalize(value, type.elem) }; @@ -230,7 +231,7 @@ function normalize(value: unknown, type: GoType): GoValue { return { k: "struct", entries: type.fields.map( - (field) => [field.go, normalize(record[field.json], field.type)] as const, + (field) => [field.name, normalize(record[field.json], field.type)] as const, ), }; } @@ -239,13 +240,13 @@ function normalize(value: unknown, type: GoType): GoValue { } } -/** Mirror `encoding/json` decoding into `interface{}`. */ -function normalizeAny(value: unknown): GoValue { +/** Normalize a decoded JSON value into an untyped value. */ +function normalizeAny(value: unknown): NormalizedValue { if (value === undefined || value === null) return { k: "nil" }; if (typeof value === "string") return { k: "str", v: value }; if (typeof value === "boolean") return { k: "bool", v: value }; if (typeof value === "number") { - // JSON numbers decode to float64 in Go's interface{} world. + // JSON numbers decode to float64. return { k: "float", v: value, bits: 64 }; } if (Array.isArray(value)) { @@ -267,13 +268,12 @@ function normalizeAny(value: unknown): GoValue { } /** - * Formats an RFC3339 input the way Go renders a decoded `time.Time` with `time.RFC3339Nano`: the + * Formats an RFC3339 input in `RFC3339Nano` form: the * fraction truncated (not rounded) to at most 9 digits, then trailing zeros trimmed and a zero * offset rendered as `Z`. */ -function normalizeGoTime(value: string): string { - // Both `.` and `,` are accepted as the fractional separator on decode; normalize to the dot Go - // emits. +function normalizeTime(value: string): string { + // Both `.` and `,` are accepted as the fractional separator on decode; normalize to the dot. const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})([.,]\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); if (match === null) return value; const [, base, fraction, offset] = match; @@ -287,10 +287,9 @@ function normalizeGoTime(value: string): string { } /** - * Shortest round-trip digits for a float32 value, matching `strconv.FormatFloat(f, 'g', -1, 32)`'s - * Ryu algorithm: the fewest significant digits that parse back to the same float32, with an exact + * Shortest round-trip digits for a float32 value (Ryu algorithm): the fewest significant digits that parse back to the same float32, with an exact * decimal tie rounded to the even final digit (unlike JS `toPrecision`, which rounds half up). - * Returns `e<±exp>` for {@link goFormatFloat}. + * Returns `e<±exp>` for {@link formatStructFloat}. */ function shortestFloat32(value: number): string { const rounded = Math.fround(value); @@ -359,16 +358,15 @@ function roundDecimalDigits( } /** - * `strconv.FormatFloat(f, 'g', -1, bits)`: shortest digits, switching to - * scientific notation when the decimal exponent is < -4 or >= 6 (Go uses - * `eprec = 6` for shortest formatting), with a sign and >= 2 exponent digits. + * Shortest round-trip digits, switching to scientific notation when the decimal exponent is + * < -4 or >= 6, with a sign and >= 2 exponent digits. */ -export function goFormatFloat(value: number, bits: 32 | 64): string { +export function formatStructFloat(value: number, bits: 32 | 64): string { if (Number.isNaN(value)) return "NaN"; if (value === Infinity) return "+Inf"; if (value === -Infinity) return "-Inf"; const repr = bits === 32 ? shortestFloat32(value) : String(value); - // JS String(-0) drops the sign; Go's FormatFloat keeps it ("-0"). + // JS String(-0) drops the sign; the output keeps it ("-0"). const negative = repr.startsWith("-") || Object.is(value, -0); const unsigned = negative ? repr.slice(1) : repr; // Decompose into digits + decimal exponent. @@ -405,14 +403,13 @@ export function goFormatFloat(value: number, bits: 32 | 64): string { } /** - * Encode a decoded payload as the Go CLI's `-o yaml` output for the given Go - * struct spec. Returns the full document bytes (trailing newline included). + * Encode a decoded payload as `-o yaml` output for the given struct spec. Returns the full document bytes (trailing newline included). */ -export function encodeGoYaml(value: unknown, type: GoType): string { +export function encodeStructYaml(value: unknown, type: OutputShape): string { return yamlDocument(normalize(value, type)); } -function yamlDocument(root: GoValue): string { +function yamlDocument(root: NormalizedValue): string { switch (root.k) { case "slice": if (root.items.length === 0) return "[]\n"; @@ -433,43 +430,40 @@ function yamlDocument(root: GoValue): string { } /** - * A populated `nullable.Nullable[T]` is a `map[bool]T`; yaml.v3 renders the - * bool key plain (`true:` / `false:`), unlike the string keys `"true"` would - * produce. + * A populated nullable renders as a mapping with a plain bool key (`true:` / `false:`), + * unlike the quoted string keys `"true"` would produce. */ -function yamlNullableBlock(present: boolean, value: GoValue, indent: number): string { +function yamlNullableBlock(present: boolean, value: NormalizedValue, indent: number): string { const pad = " ".repeat(indent); return `${pad}${present ? "true" : "false"}:${yamlValueSuffix(value, indent)}`; } -/** yaml.v3's indent algorithm: children of a mapping align to the next 4-column stop. */ +/** Children of a mapping align to the next 4-column stop. */ function yamlNextIndent(indent: number): number { return 4 * Math.floor((indent + 4) / 4); } function yamlStructEntries( - entries: ReadonlyArray, -): ReadonlyArray { - // yaml.v3 lowercases Go field names wholesale (no yaml tags on these structs). - return entries.map(([go, value]) => [go.toLowerCase(), value] as const); + entries: ReadonlyArray, +): ReadonlyArray { + // Field names are lowercased wholesale in YAML output. + return entries.map(([name, value]) => [name.toLowerCase(), value] as const); } function yamlMapEntries( - entries: ReadonlyArray, -): ReadonlyArray { + entries: ReadonlyArray, +): ReadonlyArray { return [...entries].sort(([a], [b]) => (yamlKeyLess(a, b) ? -1 : yamlKeyLess(b, a) ? 1 : 0)); } /** - * Unicode code-point string ordering, matching both `sort.Strings` (UTF-8 byte order) and - * yaml.v3's `keyList.Less` (rune order) — the two are equivalent, and both differ from JS `<` - * (UTF-16 code-unit order) when an astral character meets a high-BMP one (Go sorts U+E000 before - * U+1F600, UTF-16 the reverse). + * Unicode code-point string ordering (equivalent to UTF-8 byte order), which differs from JS `<` + * (UTF-16 code-unit order) when an astral character meets a high-BMP one (code-point order sorts + * U+E000 before U+1F600, UTF-16 the reverse). * - * Also used by `go-output.encoders.ts`'s `sortKeysDeep`, since `encoding/json`'s map-key sort is - * the same order. + * Also used by `output.encoders.ts`'s `sortKeysDeep`. */ -export function goStringCompare(a: string, b: string): number { +export function compareByCodepoint(a: string, b: string): number { let i = 0; while (i < a.length && i < b.length) { const ac = a.codePointAt(i) as number; @@ -481,8 +475,8 @@ export function goStringCompare(a: string, b: string): number { } /** - * yaml.v3's `keyList.Less` natural string ordering. Digit runs use `unicode.IsDigit` (any Unicode - * `Nd` digit), so a non-ASCII digit like Arabic-Indic sorts by its raw code point and ends up + * Natural string ordering: runs of digits compare numerically. Digit runs match any Unicode + * `Nd` digit, so a non-ASCII digit like Arabic-Indic sorts by its raw code point and ends up * after ASCII digit runs. The ASCII-only {@link isDigit} stays for the scalar parser. */ function yamlKeyLess(a: string, b: string): boolean { @@ -498,7 +492,7 @@ function yamlKeyLess(a: string, b: string): boolean { } const al = isLetter(ac); const bl = isLetter(bc); - // Go compares runes (`ar[i] < br[i]`), i.e. code points, not UTF-16 units. + // Compare code points, not UTF-16 units. if (al && bl) return (ac.codePointAt(0) as number) < (bc.codePointAt(0) as number); if (al || bl) return digits ? al : bl; let an = 0n; @@ -514,7 +508,7 @@ function yamlKeyLess(a: string, b: string): boolean { } let ai = i; let bi = i; - // Go accumulates into `int64` without overflow checks, so a 19+-digit run wraps negative and + // Digit runs accumulate into `int64` without overflow checks, so a 19+-digit run wraps negative and // sorts before a shorter positive run. `BigInt.asIntN(64, …)` reproduces the wrap. for (; ai < ar.length && isSortDigit(ar[ai] as string); ai++) { an = BigInt.asIntN(64, an * 10n + BigInt(((ar[ai] as string).codePointAt(0) as number) - 48)); @@ -529,7 +523,7 @@ function yamlKeyLess(a: string, b: string): boolean { return ar.length < br.length; } -/** yaml.v3 sorter's `unicode.IsDigit` — any Unicode decimal digit (`Nd`). */ +/** Any Unicode decimal digit (`Nd`), as used by the key sorter. */ function isSortDigit(c: string): boolean { return /\p{Nd}/u.test(c); } @@ -542,7 +536,10 @@ function isLetter(c: string): boolean { return /\p{L}/u.test(c); } -function yamlMapping(entries: ReadonlyArray, indent: number): string { +function yamlMapping( + entries: ReadonlyArray, + indent: number, +): string { const pad = " ".repeat(indent); let out = ""; for (const [key, value] of entries) { @@ -556,7 +553,7 @@ function yamlMapping(entries: ReadonlyArray, indent: * Everything after `key:` — either ` \n`, a block-literal header plus * content lines, or `\n` plus an indented child block. */ -function yamlValueSuffix(value: GoValue, indent: number): string { +function yamlValueSuffix(value: NormalizedValue, indent: number): string { switch (value.k) { case "nil": return " null\n"; @@ -591,7 +588,7 @@ function yamlValueSuffix(value: GoValue, indent: number): string { } } -function yamlSequence(items: ReadonlyArray, indent: number): string { +function yamlSequence(items: ReadonlyArray, indent: number): string { const pad = " ".repeat(indent); let out = ""; for (const item of items) { @@ -605,7 +602,7 @@ function yamlSequence(items: ReadonlyArray, indent: number): string { break; } // Compact form: the first key rides on the `- ` line; the block keeps - // a +2 indent (yaml.v3 special-cases indent inside sequence items). + // a +2 indent (indent inside sequence items is special-cased). const block = yamlMapping(entries, indent + 2); out += `${pad}- ${block.slice(indent + 2)}`; break; @@ -644,7 +641,7 @@ function yamlSequence(items: ReadonlyArray, indent: number): string { return out; } -function yamlScalar(value: GoValue): string { +function yamlScalar(value: NormalizedValue): string { switch (value.k) { case "nil": return "null"; @@ -653,7 +650,7 @@ function yamlScalar(value: GoValue): string { case "int": return String(value.v); case "float": - return goFormatFloat(value.v, value.bits); + return formatStructFloat(value.v, value.bits); case "time": return value.v; case "str": { @@ -675,7 +672,7 @@ function yamlKeyScalar(key: string): string { type YamlStringStyle = "plain" | "single" | "double" | "literal"; /** - * yaml.v3's style selection: literal for multi-line strings, double quotes for a string that + * Style selection: literal for multi-line strings, double quotes for a string that * resolves to a non-string tag, otherwise downgraded from plain to single (or double) based on * scalar analysis. */ @@ -695,7 +692,7 @@ function yamlStringStyle(s: string): YamlStringStyle { } /** - * Characters yaml.v3 treats as "special" (not printable) or line breaks other + * Characters treated as "special" (not printable) or line breaks other * than `\n` — all of these force double-quoted style with escapes. U+2028 and * U+2029 are technically YAML line breaks, but every realistic payload * containing them round-trips through the double-quoted `\L` / `\P` escapes. @@ -711,8 +708,7 @@ function yamlHasSpecialChars(s: string): boolean { } /** - * libyaml's `is_printable` in code-point terms. The byte-oriented original never accepts a 4-byte - * UTF-8 lead, so every astral character — along with C0/C1 controls, DEL, surrogates, the U+FEFF + * YAML printability in code-point terms: every astral character — along with C0/C1 controls, DEL, surrogates, the U+FEFF * BOM, and U+FFFE/U+FFFF — is "not printable" and gets double-quoted escapes. */ function yamlIsPrintable(code: number): boolean { @@ -738,7 +734,7 @@ function yamlPlainDisallowed(s: string): boolean { } /** - * Would yaml.v3's `resolve("", s)` produce a non-string tag? Also covers the + * Would the plain scalar `s` resolve to a non-string YAML tag? Also covers the * YAML 1.1 "old bool" and base-60 spellings the encoder force-quotes. */ function yamlResolvesToString(s: string): boolean { @@ -747,15 +743,15 @@ function yamlResolvesToString(s: string): boolean { if (YAML_BASE60.test(s)) return false; const first = s[0] as string; if (first === ".") { - // strconv.ParseFloat errors on overflow (±Inf), so an overflowing spelling like `1e999` stays + // Float parsing errors on overflow (±Inf), so an overflowing spelling like `1e999` stays // a string and needs no quoting. return !(/^\.\d+(?:[eE][+-]?\d+)?$/.test(s) && Number.isFinite(Number(s))); } if (first === "+" || first === "-" || isDigit(first)) { if (yamlIsTimestamp(s)) return false; const plain = s.replaceAll("_", ""); - if (goParseIntBase0(plain)) return false; - // An overflowing float (→ ±Inf) is a ParseFloat error, so it resolves as a string and stays + if (parsesAsBaseZeroInt(plain)) return false; + // An overflowing float (→ ±Inf) is a float parse error, so it resolves as a string and stays // plain; an underflowing one (1e-999 → 0) succeeds and stays float-tagged, hence quoted. // `Number` mirrors the accepted shapes since YAML_STYLE_FLOAT gates the syntax first. if (YAML_STYLE_FLOAT.test(plain) && Number.isFinite(Number(plain))) return false; @@ -812,8 +808,8 @@ const YAML_RESOLVE_MAP = new Set([ const YAML_BASE60 = /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$/; const YAML_STYLE_FLOAT = /^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$/; -/** `strconv.ParseInt(s, 0, 64)` / `ParseUint` success (underscores pre-stripped). */ -function goParseIntBase0(plain: string): boolean { +/** Whether `plain` parses as a base-0 int64/uint64 (underscores pre-stripped). */ +function parsesAsBaseZeroInt(plain: string): boolean { let body = plain; let negative = false; if (body.startsWith("+") || body.startsWith("-")) { @@ -846,20 +842,18 @@ function goParseIntBase0(plain: string): boolean { } else { return false; } - // resolve() falls back from ParseInt to ParseUint, so the accepted range is + // Resolution falls back from int64 to uint64, so the accepted range is // [-2^63, 2^64) — anything beyond either bound is not an int. if (negative) return parsed <= 9223372036854775808n; return parsed < 18446744073709551616n; } /** - * yaml.v3's `parseTimestamp` layouts, which delegate to `time.Parse` — so calendar dates and zone - * offsets are validated exactly like Go's time package (`2025-02-31` and `2100-02-29` stay plain, - * `2024-02-29` is a timestamp). + * Timestamp layouts with validated calendar dates and zone offsets (`2025-02-31` and + * `2100-02-29` stay plain, `2024-02-29` is a timestamp). */ function yamlIsTimestamp(s: string): boolean { - // The fraction separator is `.` or `,` — yaml.v3 resolves timestamps through `time.Parse`, - // which accepts either. + // The fraction separator is `.` or `,`. const match = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,]\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( s, @@ -872,7 +866,7 @@ function yamlIsTimestamp(s: string): boolean { if (separator !== " " && offset === undefined) return false; if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; } - // time.Parse's zone-offset range checks: the hour is rejected above 24 and the minute above 60 + // Zone-offset range checks: the hour is rejected above 24 and the minute above 60 // — `+24:59` and `+00:60` are accepted, `+25:00` and `+23:99` are not. if (offset !== undefined && offset !== "Z") { if (Number(offset.slice(1, 3)) > 24 || Number(offset.slice(4, 6)) > 60) return false; @@ -880,12 +874,12 @@ function yamlIsTimestamp(s: string): boolean { const month = Number(monthRaw); const day = Number(dayRaw); if (month < 1 || month > 12) return false; - if (day < 1 || day > goDaysInMonth(Number(yearRaw), month)) return false; + if (day < 1 || day > daysInMonth(Number(yearRaw), month)) return false; return true; } -/** `time.Parse`'s "day out of range" bound (`daysIn`, proleptic Gregorian). */ -function goDaysInMonth(year: number, month: number): number { +/** Days in a month of the proleptic Gregorian calendar. */ +function daysInMonth(year: number, month: number): number { if (month === 2) { const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); return leap ? 29 : 28; @@ -952,8 +946,7 @@ function yamlDoubleQuoted(s: string): string { out += "\\P"; break; default: - // Non-printables escape by rune width like yaml.v3's double-quoted - // writer: `\xXX`, `\uXXXX`, or `\U00XXXXXX` with uppercase hex. + // Non-printables escape as `\xXX`, `\uXXXX`, or `\U00XXXXXX` with uppercase hex. if (yamlIsPrintable(code)) { out += ch; } else if (code <= 0xff) { @@ -973,10 +966,8 @@ function yamlDoubleQuoted(s: string): string { * explicit `4` indentation indicator when the first line starts with a space * or is empty, and content indented to the next 4-column stop. * - * Unlike Go's streaming bufio-backed encoder (which can flush partial output - * before a later error), this builds the whole document in memory — callers - * emit all-or-nothing, which only differs observably from Go on multi-KB - * payloads that fail mid-encode. + * Builds the whole document in memory — callers emit all-or-nothing, so no partial output + * is flushed when a later field fails mid-encode. */ function yamlBlockLiteral(s: string, indent: number): string { const contentIndent = yamlNextIndent(indent); @@ -986,7 +977,7 @@ function yamlBlockLiteral(s: string, indent: number): string { const indicator = s.startsWith(" ") || s.startsWith("\n") ? "4" : ""; const lines = s.split("\n"); if (s.endsWith("\n")) lines.pop(); - // yaml.v3 merges a leading empty line's break with the header newline (e.g. "\nx" → `|4-\n + // A leading empty line's break merges with the header newline (e.g. "\nx" → `|4-\n // x\n`). if (lines[0] === "") lines.shift(); const body = lines.map((line) => (line.length === 0 ? "" : `${pad}${line}`)).join("\n"); @@ -994,14 +985,14 @@ function yamlBlockLiteral(s: string, indent: number): string { } /** - * Thrown when BurntSushi would refuse the payload: a populated - * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed - * on `snippets list -o toml`) or a `nil` element inside an inline array. + * Thrown when the payload has no TOML form: a populated nullable field (its bool key is not a + * valid TOML key — observed on `snippets list -o toml`) or a null element inside an inline array. */ -export class GoTomlEncodeError extends Data.Error<{ readonly message: string }> { +export class TomlEncodeError extends Data.Error<{ readonly message: string }> { + // The fingerprint and `name` are the stable telemetry/output identity of this error. static readonly [ErrorActionabilityFingerprintId] = "GoTomlEncodeError"; override readonly name = "GoTomlEncodeError"; - constructor(message = "toml: cannot encode a map with non-string key type") { + constructor(message = "cannot encode a map with non-string keys") { super({ message }); } @@ -1011,15 +1002,13 @@ export class GoTomlEncodeError extends Data.Error<{ readonly message: string }> } /** - * Encodes a decoded payload as the Go CLI's `-o toml` output. Returns the full document, which can - * be empty (BurntSushi emits nothing for an all-nil payload). Throws {@link GoTomlEncodeError} to - * match Go's runtime failure on a populated nullable field. + * Encodes a decoded payload as `-o toml` output. Returns the full document, which can + * be empty (an all-nil payload emits nothing). Throws {@link TomlEncodeError} on + * a populated nullable field. * - * On the error path only, Go's real stdout can already contain partial buffered output that this - * in-memory port doesn't reproduce; emit nothing on error rather than the accumulated prefix, - * which would emit more than Go does. + * Emits nothing on error rather than the accumulated prefix. */ -export function encodeGoToml(value: unknown, type: GoType): string { +export function encodeStructToml(value: unknown, type: OutputShape): string { const state = { out: "", hasWritten: false }; tomlEncode(state, [], normalize(value, type)); return state.out; @@ -1044,7 +1033,7 @@ function tomlIndent(key: ReadonlyArray): string { return " ".repeat(Math.max(key.length - 1, 0)); } -function tomlIsTable(value: GoValue): boolean { +function tomlIsTable(value: NormalizedValue): boolean { switch (value.k) { case "struct": return true; @@ -1053,15 +1042,14 @@ function tomlIsTable(value: GoValue): boolean { case "nullable": return true; case "slice": - // Array-of-tables only when non-empty with table elements (BurntSushi's - // isTableArray returns false for empty slices). + // Array-of-tables only when non-empty with table elements. return value.tables && value.items.length > 0; default: return false; } } -function tomlIsNil(value: GoValue): boolean { +function tomlIsNil(value: NormalizedValue): boolean { switch (value.k) { case "nil": return true; @@ -1076,7 +1064,7 @@ function tomlIsNil(value: GoValue): boolean { } } -function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue): void { +function tomlEncode(state: TomlState, key: ReadonlyArray, value: NormalizedValue): void { if (tomlIsNil(value)) return; switch (value.k) { case "struct": @@ -1084,8 +1072,8 @@ function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue tomlTable(state, key, value); return; case "nullable": - // Populated nullable.Nullable[T] is a map[bool]T — BurntSushi panics. - throw new GoTomlEncodeError(); + // A populated nullable has a bool key, which TOML cannot encode. + throw new TomlEncodeError(); case "slice": if (tomlIsTable(value)) { tomlArrayOfTables(state, key, value.items); @@ -1099,19 +1087,19 @@ function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue } /** - * Map/struct entries in BurntSushi's write order: map entries sorted by - * {@link goStringCompare} (structs keep declaration order), then both + * Map/struct entries in TOML write order: map entries sorted by + * {@link compareByCodepoint} (structs keep declaration order), then both * partitioned into non-table ("direct") and table ("sub") groups via - * {@link tomlIsTable} — `eStruct`/`eMap` always write direct fields before + * {@link tomlIsTable} — direct fields are always written before * sub-tables. */ -function tomlOrderedEntries(value: Extract): { - direct: ReadonlyArray; - sub: ReadonlyArray; +function tomlOrderedEntries(value: Extract): { + direct: ReadonlyArray; + sub: ReadonlyArray; } { const entries = value.k === "map" - ? [...value.entries].sort(([a], [b]) => goStringCompare(a, b)) + ? [...value.entries].sort(([a], [b]) => compareByCodepoint(a, b)) : value.entries; return { direct: entries.filter(([, v]) => !tomlIsTable(v)), @@ -1122,7 +1110,7 @@ function tomlOrderedEntries(value: Extract): { function tomlTable( state: TomlState, key: ReadonlyArray, - value: Extract, + value: Extract, ): void { if (key.length === 1) { // Extra newline between top-level tables. @@ -1145,7 +1133,7 @@ function tomlTable( function tomlArrayOfTables( state: TomlState, key: ReadonlyArray, - items: ReadonlyArray, + items: ReadonlyArray, ): void { for (const item of items) { if (tomlIsNil(item)) continue; @@ -1158,12 +1146,12 @@ function tomlArrayOfTables( tomlEncode(state, [...key, name], v); } } else if (item.k === "nullable") { - throw new GoTomlEncodeError(); + throw new TomlEncodeError(); } } } -function tomlKeyValue(state: TomlState, key: ReadonlyArray, value: GoValue): void { +function tomlKeyValue(state: TomlState, key: ReadonlyArray, value: NormalizedValue): void { const name = key[key.length - 1] as string; tomlWrite(state, `${tomlIndent(key)}${tomlKeyName(name)} = ${tomlElement(value)}\n`); } @@ -1174,7 +1162,7 @@ function tomlKeyName(name: string): string { return TOML_BARE_KEY.test(name) ? name : tomlQuoted(name); } -function tomlElement(value: GoValue): string { +function tomlElement(value: NormalizedValue): string { switch (value.k) { case "str": return tomlQuoted(value.v); @@ -1183,7 +1171,7 @@ function tomlElement(value: GoValue): string { case "int": return String(value.v); case "float": { - const repr = goFormatFloat(value.v, value.bits); + const repr = formatStructFloat(value.v, value.bits); // TOML floats must carry a decimal point unless in exponent form. return repr.includes(".") || repr.includes("e") ? repr : `${repr}.0`; } @@ -1195,16 +1183,16 @@ function tomlElement(value: GoValue): string { case "map": return tomlInlineTable(value); case "nullable": - throw new GoTomlEncodeError(); + throw new TomlEncodeError(); case "nil": - // BurntSushi's `eElement` rejects nil inline-array elements (`[null, "x"]` fails), while nil + // Null inline-array elements are rejected (`[null, "x"]` fails), while null // map values are silently skipped. - throw new GoTomlEncodeError("toml: cannot encode array with nil element"); + throw new TomlEncodeError("cannot encode an array with a null element"); } } /** - * BurntSushi's inline-table form, used for map/struct elements of arrays that aren't + * The inline-table form, used for map/struct elements of arrays that aren't * arrays-of-tables: `{k = v, ...}` with nil entries skipped and non-table values before table * values, map keys byte-sorted within each group. * @@ -1212,14 +1200,14 @@ function tomlElement(value: GoValue): string { * written, so a skipped nil entry in the final position leaves a dangling `", "` (e.g. * `[{"10":1,"b":null},false]` → `[{10 = 1.0, }, false]`). */ -function tomlInlineTable(value: Extract): string { +function tomlInlineTable(value: Extract): string { let out = "{"; if (value.k === "map") { - const sorted = [...value.entries].sort(([a], [b]) => goStringCompare(a, b)); + const sorted = [...value.entries].sort(([a], [b]) => compareByCodepoint(a, b)); const direct = sorted.filter(([, v]) => !tomlIsTable(v)); const sub = sorted.filter(([, v]) => tomlIsTable(v)); const writeGroup = ( - group: ReadonlyArray, + group: ReadonlyArray, trailingComma: boolean, ): void => { for (const [index, [name, v]] of group.entries()) { @@ -1243,7 +1231,7 @@ function tomlInlineTable(value: Extract): stri return `${out}}`; } -/** BurntSushi's `dblQuotedReplacer` escape set. */ +/** The double-quoted TOML string escape set. */ function tomlQuoted(s: string): string { let out = '"'; for (const ch of s) { diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts b/apps/cli/src/command-internal/struct-output.encoders.unit.test.ts similarity index 50% rename from apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts rename to apps/cli/src/command-internal/struct-output.encoders.unit.test.ts index 0c8900aac7..93af2144df 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/struct-output.encoders.unit.test.ts @@ -1,52 +1,46 @@ import { describe, expect, it } from "vitest"; import { - GoTomlEncodeError, - encodeGoToml, - encodeGoYaml, - goAny, - goBool, - goFieldName, - goFloat32, - goFloat64, - goFormatFloat, - goInt, - goMap, - goNullable, - goPtr, - goSlice, - goString, - goStruct, - goTime, - goTomlListWrapper, - goUuid, -} from "./go-struct-output.encoders.ts"; - -/** - * Golden byte strings in this file were captured from a scratch Go program calling - * `utils.EncodeOutput`, using the same BurntSushi toml and yaml.v3 versions pinned in the - * reference `go.mod`. - */ + TomlEncodeError, + encodeStructToml, + encodeStructYaml, + shapeAny, + shapeBool, + pascalCaseFieldName, + shapeFloat32, + shapeFloat64, + formatStructFloat, + shapeInt, + shapeMap, + shapeNullable, + shapePtr, + shapeSlice, + shapeString, + shapeStruct, + shapeTime, + shapeTomlListWrapper, + shapeUuid, +} from "./struct-output.encoders.ts"; // Mirrors the branch response struct. -const BRANCH_RESPONSE = goStruct([ - ["created_at", goTime], - ["deletion_scheduled_at", goPtr(goTime)], - ["git_branch", goPtr(goString)], - ["id", goUuid], - ["is_default", goBool], - ["latest_check_run_id", goPtr(goFloat32)], - ["name", goString], - ["notify_url", goPtr(goString)], - ["parent_project_ref", goString], - ["persistent", goBool], - ["pr_number", goPtr(goInt)], - ["preview_project_status", goPtr(goString)], - ["project_ref", goString], - ["review_requested_at", goPtr(goTime)], - ["status", goString], - ["updated_at", goTime], - ["with_data", goBool], +const BRANCH_RESPONSE = shapeStruct([ + ["created_at", shapeTime], + ["deletion_scheduled_at", shapePtr(shapeTime)], + ["git_branch", shapePtr(shapeString)], + ["id", shapeUuid], + ["is_default", shapeBool], + ["latest_check_run_id", shapePtr(shapeFloat32)], + ["name", shapeString], + ["notify_url", shapePtr(shapeString)], + ["parent_project_ref", shapeString], + ["persistent", shapeBool], + ["pr_number", shapePtr(shapeInt)], + ["preview_project_status", shapePtr(shapeString)], + ["project_ref", shapeString], + ["review_requested_at", shapePtr(shapeTime)], + ["status", shapeString], + ["updated_at", shapeTime], + ["with_data", shapeBool], ]); const SAMPLE_BRANCH = { @@ -63,7 +57,7 @@ const SAMPLE_BRANCH = { with_data: true, }; -// All pointer fields absent — Go zero-fills the value fields. +// All pointer fields absent — the value fields are zero-filled. const ZERO_BRANCH = { name: "Production", is_default: true, @@ -72,10 +66,10 @@ const ZERO_BRANCH = { status: "FUNCTIONS_DEPLOYED", }; -describe("encodeGoToml", () => { - it("matches Go byte-for-byte for a branches list wrapper (PascalCase, nil pointers omitted, native datetimes)", () => { - const wrapper = goTomlListWrapper("branches", BRANCH_RESPONSE); - expect(encodeGoToml({ branches: [SAMPLE_BRANCH, ZERO_BRANCH] }, wrapper)).toBe( +describe("encodeStructToml", () => { + it("matches the golden output for a branches list wrapper (PascalCase, nil pointers omitted, native datetimes)", () => { + const wrapper = shapeTomlListWrapper("branches", BRANCH_RESPONSE); + expect(encodeStructToml({ branches: [SAMPLE_BRANCH, ZERO_BRANCH] }, wrapper)).toBe( `[[branches]] CreatedAt = 2026-05-27T01:02:03Z GitBranch = "feat-1" @@ -105,7 +99,7 @@ describe("encodeGoToml", () => { }); it("emits a top-level struct without a table header (branches create)", () => { - expect(encodeGoToml(SAMPLE_BRANCH, BRANCH_RESPONSE)).toBe( + expect(encodeStructToml(SAMPLE_BRANCH, BRANCH_RESPONSE)).toBe( `CreatedAt = 2026-05-27T01:02:03Z GitBranch = "feat-1" Id = "11111111-2222-3333-4444-555555555555" @@ -122,60 +116,60 @@ WithData = true }); it("emits nothing for a nil list and `key = []` for a decoded empty list", () => { - const wrapper = goTomlListWrapper("branches", BRANCH_RESPONSE); - // Go: `var result []api.BranchResponse` stays nil when empty → no output. - expect(encodeGoToml({ branches: undefined }, wrapper)).toBe(""); - // Go: a decoded `[]` is a non-nil empty slice → `branches = []`. - expect(encodeGoToml({ branches: [] }, wrapper)).toBe("branches = []\n"); + const wrapper = shapeTomlListWrapper("branches", BRANCH_RESPONSE); + // An absent list → no output. + expect(encodeStructToml({ branches: undefined }, wrapper)).toBe(""); + // A decoded `[]` is an empty list → `branches = []`. + expect(encodeStructToml({ branches: [] }, wrapper)).toBe("branches = []\n"); }); it("nests sub-tables after primitives with 2-space indentation (hostnames shape)", () => { // Mirrors the custom-hostname update response struct. - const spec = goStruct([ - ["custom_hostname", goString], + const spec = shapeStruct([ + ["custom_hostname", shapeString], [ "data", - goStruct([ - ["errors", goSlice(goAny)], - ["messages", goSlice(goAny)], + shapeStruct([ + ["errors", shapeSlice(shapeAny)], + ["messages", shapeSlice(shapeAny)], [ "result", - goStruct([ - ["custom_origin_server", goString], - ["hostname", goString], - ["id", goString], + shapeStruct([ + ["custom_origin_server", shapeString], + ["hostname", shapeString], + ["id", shapeString], [ "ownership_verification", - goStruct([ - ["name", goString], - ["type", goString], - ["value", goString], + shapeStruct([ + ["name", shapeString], + ["type", shapeString], + ["value", shapeString], ]), ], [ "ssl", - goStruct([ - ["status", goString], - ["validation_errors", goPtr(goSlice(goAny))], + shapeStruct([ + ["status", shapeString], + ["validation_errors", shapePtr(shapeSlice(shapeAny))], [ "validation_records", - goSlice( - goStruct([ - ["txt_name", goString], - ["txt_value", goString], + shapeSlice( + shapeStruct([ + ["txt_name", shapeString], + ["txt_value", shapeString], ]), ), ], ]), ], - ["status", goString], - ["verification_errors", goPtr(goSlice(goString))], + ["status", shapeString], + ["verification_errors", shapePtr(shapeSlice(shapeString))], ]), ], - ["success", goBool], + ["success", shapeBool], ]), ], - ["status", goString], + ["status", shapeString], ]); const payload = { custom_hostname: "custom.example.com", @@ -198,7 +192,7 @@ WithData = true }, }, }; - expect(encodeGoToml(payload, spec)).toBe( + expect(encodeStructToml(payload, spec)).toBe( `CustomHostname = "custom.example.com" Status = "2_initiated" @@ -223,35 +217,35 @@ Status = "2_initiated" ); }); - it("escapes strings like BurntSushi and quotes string-typed timestamps (sso provider)", () => { - const spec = goStruct([ - ["created_at", goPtr(goString)], + it("escapes TOML strings and quotes string-typed timestamps (sso provider)", () => { + const spec = shapeStruct([ + ["created_at", shapePtr(shapeString)], [ "domains", - goPtr( - goSlice( - goStruct([ - ["created_at", goPtr(goString)], - ["domain", goPtr(goString)], - ["updated_at", goPtr(goString)], + shapePtr( + shapeSlice( + shapeStruct([ + ["created_at", shapePtr(shapeString)], + ["domain", shapePtr(shapeString)], + ["updated_at", shapePtr(shapeString)], ]), ), ), ], - ["id", goString], + ["id", shapeString], [ "saml", - goPtr( - goStruct([ - ["attribute_mapping", goPtr(goStruct([["keys", goMap(goAny)]]))], - ["entity_id", goString], - ["metadata_url", goPtr(goString)], - ["metadata_xml", goPtr(goString)], - ["name_id_format", goPtr(goString)], + shapePtr( + shapeStruct([ + ["attribute_mapping", shapePtr(shapeStruct([["keys", shapeMap(shapeAny)]]))], + ["entity_id", shapeString], + ["metadata_url", shapePtr(shapeString)], + ["metadata_xml", shapePtr(shapeString)], + ["name_id_format", shapePtr(shapeString)], ]), ), ], - ["updated_at", goPtr(goString)], + ["updated_at", shapePtr(shapeString)], ]); const payload = { id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", @@ -264,7 +258,7 @@ Status = "2_initiated" '\n&', }, }; - expect(encodeGoToml(payload, spec)).toBe( + expect(encodeStructToml(payload, spec)).toBe( `CreatedAt = "2026-05-27T01:02:03.123456Z" Id = "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31" UpdatedAt = "2026-05-27T01:02:03.123456Z" @@ -280,17 +274,17 @@ UpdatedAt = "2026-05-27T01:02:03.123456Z" ); }); - it("keeps hand-written Go struct declaration order (services imageVersion)", () => { - const spec = goTomlListWrapper( + it("keeps hand-written struct declaration order (services imageVersion)", () => { + const spec = shapeTomlListWrapper( "services", - goStruct([ - ["name", goString], - ["local", goString], - ["remote", goString], + shapeStruct([ + ["name", shapeString], + ["local", shapeString], + ["remote", shapeString], ]), ); expect( - encodeGoToml( + encodeStructToml( { services: [{ name: "supabase/postgres", local: "17.4.1.037", remote: "" }] }, spec, ), @@ -304,33 +298,33 @@ UpdatedAt = "2026-05-27T01:02:03.123456Z" }); it("renders inline primitive arrays (network bans wrapper)", () => { - const spec = goStruct([["banned_ips", goSlice(goString), "banned_ips"]]); - expect(encodeGoToml({ banned_ips: ["1.2.3.4", "5.6.7.8"] }, spec)).toBe( + const spec = shapeStruct([["banned_ips", shapeSlice(shapeString), "banned_ips"]]); + expect(encodeStructToml({ banned_ips: ["1.2.3.4", "5.6.7.8"] }, spec)).toBe( 'banned_ips = ["1.2.3.4", "5.6.7.8"]\n', ); }); - it("skips nil nullable fields and fails like Go on populated ones", () => { - const spec = goStruct([ - ["desc", goNullable(goString)], - ["name", goString], + it("skips nil nullable fields and fails on populated ones", () => { + const spec = shapeStruct([ + ["desc", shapeNullable(shapeString)], + ["name", shapeString], ]); - expect(encodeGoToml({ name: "x" }, spec)).toBe('Name = "x"\n'); - expect(() => encodeGoToml({ name: "x", desc: null }, spec)).toThrow( - new GoTomlEncodeError().message, + expect(encodeStructToml({ name: "x" }, spec)).toBe('Name = "x"\n'); + expect(() => encodeStructToml({ name: "x", desc: null }, spec)).toThrow( + new TomlEncodeError().message, ); - expect(() => encodeGoToml({ name: "x", desc: "d" }, spec)).toThrow( - "toml: cannot encode a map with non-string key type", + expect(() => encodeStructToml({ name: "x", desc: "d" }, spec)).toThrow( + "cannot encode a map with non-string keys", ); }); - it("renders floats with a decimal point and Go's exponent form", () => { - const spec = goStruct([ - ["f1", goFloat32], - ["f2", goFloat64], - ["f6", goFloat64], + it("renders floats with a decimal point and the exponent form", () => { + const spec = shapeStruct([ + ["f1", shapeFloat32], + ["f2", shapeFloat64], + ["f6", shapeFloat64], ]); - expect(encodeGoToml({ f1: 1, f2: 1000000, f6: 1234567 }, spec)).toBe( + expect(encodeStructToml({ f1: 1, f2: 1000000, f6: 1234567 }, spec)).toBe( `F1 = 1.0 F2 = 1e+06 F6 = 1.234567e+06 @@ -339,9 +333,9 @@ F6 = 1.234567e+06 }); it("sorts map keys and quotes non-bare keys (branches get envs)", () => { - const spec = goMap(goString); + const spec = shapeMap(shapeString); expect( - encodeGoToml( + encodeStructToml( { SUPABASE_ANON_KEY: "anon", POSTGRES_URL: "postgres://u:p@h:6543/postgres" }, spec, ), @@ -352,95 +346,95 @@ SUPABASE_ANON_KEY = "anon" ); }); - it("renders map elements of mixed interface{} arrays as inline tables like BurntSushi", () => { - const spec = goStruct([["default", goAny, "Default"]]); + it("renders map elements of mixed untyped arrays as inline tables", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); // Sorted byte order, non-table values before table values. - expect(encodeGoToml({ default: [{ b: 2, a: 1, C: 3 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ b: 2, a: 1, C: 3 }, "x"] }, spec)).toBe( 'Default = [{C = 3.0, a = 1.0, b = 2.0}, "x"]\n', ); - expect(encodeGoToml({ default: [{ a: { b: 1 }, z: 2 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ a: { b: 1 }, z: 2 }, "x"] }, spec)).toBe( 'Default = [{z = 2.0, a = {b = 1.0}}, "x"]\n', ); - expect(encodeGoToml({ default: [{ a: [{ b: 1 }], z: 2 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ a: [{ b: 1 }], z: 2 }, "x"] }, spec)).toBe( 'Default = [{z = 2.0, a = [{b = 1.0}]}, "x"]\n', ); // Non-bare keys are quoted; empty and all-nil tables collapse to {}. - expect(encodeGoToml({ default: [{ "a b": 1 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ "a b": 1 }, "x"] }, spec)).toBe( 'Default = [{"a b" = 1.0}, "x"]\n', ); - expect(encodeGoToml({ default: [{}, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); - expect(encodeGoToml({ default: [{ a: null }, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + expect(encodeStructToml({ default: [{}, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + expect(encodeStructToml({ default: [{ a: null }, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); // eMap decides the ", " separator by group position before skipping nil // entries, so a nil in the final position leaves a dangling separator. - expect(encodeGoToml({ default: [{ "10": 78797, b: null }, false] }, spec)).toBe( + expect(encodeStructToml({ default: [{ "10": 78797, b: null }, false] }, spec)).toBe( "Default = [{10 = 78797.0, }, false]\n", ); }); - it("fails like Go on nil elements inside interface{} arrays", () => { - const spec = goStruct([["default", goAny, "Default"]]); - const message = "toml: cannot encode array with nil element"; - expect(() => encodeGoToml({ default: [null, "x"] }, spec)).toThrow(message); - expect(() => encodeGoToml({ default: [null] }, spec)).toThrow(message); - expect(() => encodeGoToml({ default: [[null], "x"] }, spec)).toThrow(message); + it("fails on null elements inside untyped arrays", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); + const message = "cannot encode an array with a null element"; + expect(() => encodeStructToml({ default: [null, "x"] }, spec)).toThrow(message); + expect(() => encodeStructToml({ default: [null] }, spec)).toThrow(message); + expect(() => encodeStructToml({ default: [[null], "x"] }, spec)).toThrow(message); }); - it("truncates time fractions to nanoseconds like time.Time's decoder", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoToml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + it("truncates time fractions to nanoseconds", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructToml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoToml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + expect(encodeStructToml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.1Z\n", ); }); - it("quotes comma-fraction timestamp-shaped STRINGS like yaml.v3's resolver", () => { - const spec = goStruct([["s", goString, "S"]]); - expect(encodeGoYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + it("quotes comma-fraction timestamp-shaped strings", () => { + const spec = shapeStruct([["s", shapeString, "S"]]); + expect(encodeStructYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( 's: "2026-01-01T00:00:00,123Z"\n', ); }); - it("leaves overflowing float-shaped strings plain like yaml.v3's ParseFloat gate", () => { - const spec = goStruct([["s", goString, "S"]]); - expect(encodeGoYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); - expect(encodeGoYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); - expect(encodeGoYaml({ s: ".5e999" }, spec)).toBe("s: .5e999\n"); - expect(encodeGoYaml({ s: "1e-999" }, spec)).toBe('s: "1e-999"\n'); - expect(encodeGoYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); + it("leaves overflowing float-shaped strings plain", () => { + const spec = shapeStruct([["s", shapeString, "S"]]); + expect(encodeStructYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); + expect(encodeStructYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); + expect(encodeStructYaml({ s: ".5e999" }, spec)).toBe("s: .5e999\n"); + expect(encodeStructYaml({ s: "1e-999" }, spec)).toBe('s: "1e-999"\n'); + expect(encodeStructYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); }); - it("wraps 19+-digit numeric key runs like Go's unchecked int64 accumulation", () => { - const spec = goStruct([["default", goAny, "Default"]]); + it("wraps 19+-digit numeric key runs via unchecked int64 accumulation", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); expect( - encodeGoYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), + encodeStructYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), ).toBe("default:\n a10000000000000000000: 2\n a9000000000000000000: 1\n"); }); - it("orders Unicode-digit map keys with yaml.v3's naive rune arithmetic", () => { - const spec = goStruct([["default", goAny, "Default"]]); - expect(encodeGoYaml({ default: { a٢: 1, a3: 2, a10: 3, a9: 4 } }, spec)).toBe( + it("orders Unicode-digit map keys by code point, with numeric runs compared by ASCII digits only", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); + expect(encodeStructYaml({ default: { a٢: 1, a3: 2, a10: 3, a9: 4 } }, spec)).toBe( "default:\n a3: 2\n a9: 4\n a10: 3\n a٢: 1\n", ); }); - it("normalizes Go's accepted comma fractional separator to the dot Go re-emits", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + it("normalizes a comma fractional separator to a dot", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.123Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00,1234567895Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00,1234567895Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); }); - it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { + it("sorts map keys by UTF-8 byte order", () => { // U+E000/U+FF21 sort before the astral U+1D400/U+1F600 in UTF-8 byte order; JS `<` on UTF-16 // units would sort both astral keys first. - const spec = goMap(goString); + const spec = shapeMap(shapeString); expect( - encodeGoToml( + encodeStructToml( { "\u{1F600}": "emoji", "\uE000": "private-use", @@ -463,9 +457,9 @@ SUPABASE_ANON_KEY = "anon" }); }); -describe("encodeGoYaml", () => { - it("matches Go byte-for-byte for a branches list (lowercased keys, explicit nulls)", () => { - expect(encodeGoYaml([SAMPLE_BRANCH, ZERO_BRANCH], goSlice(BRANCH_RESPONSE))).toBe( +describe("encodeStructYaml", () => { + it("matches the golden output for a branches list (lowercased keys, explicit nulls)", () => { + expect(encodeStructYaml([SAMPLE_BRANCH, ZERO_BRANCH], shapeSlice(BRANCH_RESPONSE))).toBe( `- createdat: 2026-05-27T01:02:03Z deletionscheduledat: null gitbranch: feat-1 @@ -505,39 +499,39 @@ describe("encodeGoYaml", () => { }); it("renders an empty list as [] regardless of nil-ness", () => { - expect(encodeGoYaml([], goSlice(BRANCH_RESPONSE))).toBe("[]\n"); - expect(encodeGoYaml(undefined, goSlice(BRANCH_RESPONSE))).toBe("[]\n"); + expect(encodeStructYaml([], shapeSlice(BRANCH_RESPONSE))).toBe("[]\n"); + expect(encodeStructYaml(undefined, shapeSlice(BRANCH_RESPONSE))).toBe("[]\n"); }); it("uses 4-column indentation, block literals, and quoted string timestamps (sso show)", () => { - const spec = goStruct([ - ["created_at", goPtr(goString)], + const spec = shapeStruct([ + ["created_at", shapePtr(shapeString)], [ "domains", - goPtr( - goSlice( - goStruct([ - ["created_at", goPtr(goString)], - ["domain", goPtr(goString)], - ["updated_at", goPtr(goString)], + shapePtr( + shapeSlice( + shapeStruct([ + ["created_at", shapePtr(shapeString)], + ["domain", shapePtr(shapeString)], + ["updated_at", shapePtr(shapeString)], ]), ), ), ], - ["id", goString], + ["id", shapeString], [ "saml", - goPtr( - goStruct([ - ["attribute_mapping", goPtr(goStruct([["keys", goMap(goAny)]]))], - ["entity_id", goString], - ["metadata_url", goPtr(goString)], - ["metadata_xml", goPtr(goString)], - ["name_id_format", goPtr(goString)], + shapePtr( + shapeStruct([ + ["attribute_mapping", shapePtr(shapeStruct([["keys", shapeMap(shapeAny)]]))], + ["entity_id", shapeString], + ["metadata_url", shapePtr(shapeString)], + ["metadata_xml", shapePtr(shapeString)], + ["name_id_format", shapePtr(shapeString)], ]), ), ], - ["updated_at", goPtr(goString)], + ["updated_at", shapePtr(shapeString)], ]); const payload = { id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", @@ -550,7 +544,7 @@ describe("encodeGoYaml", () => { '\n&', }, }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `createdat: "2026-05-27T01:02:03.123456Z" domains: - createdat: "2026-05-27T01:02:03Z" @@ -570,27 +564,27 @@ updatedat: "2026-05-27T01:02:03.123456Z" ); }); - it("renders nullable fields the way yaml.v3 renders map[bool]T (api keys)", () => { + it("renders nullable fields as a mapping with a plain bool key (api keys)", () => { // Mirror of api.ApiKeyResponse. - const spec = goSlice( - goStruct([ - ["api_key", goNullable(goString)], - ["description", goNullable(goString)], - ["hash", goNullable(goString)], - ["id", goNullable(goString)], - ["inserted_at", goNullable(goTime)], - ["name", goString], - ["prefix", goNullable(goString)], - ["secret_jwt_template", goNullable(goMap(goAny))], - ["type", goNullable(goString)], - ["updated_at", goNullable(goTime)], + const spec = shapeSlice( + shapeStruct([ + ["api_key", shapeNullable(shapeString)], + ["description", shapeNullable(shapeString)], + ["hash", shapeNullable(shapeString)], + ["id", shapeNullable(shapeString)], + ["inserted_at", shapeNullable(shapeTime)], + ["name", shapeString], + ["prefix", shapeNullable(shapeString)], + ["secret_jwt_template", shapeNullable(shapeMap(shapeAny))], + ["type", shapeNullable(shapeString)], + ["updated_at", shapeNullable(shapeTime)], ]), ); const payload = [ { name: "anon", api_key: "anon-key-value", id: "key-id-1", type: "legacy" }, { name: "service_role" }, ]; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `- apikey: true: anon-key-value description: {} @@ -619,11 +613,11 @@ updatedat: "2026-05-27T01:02:03.123456Z" }); it("renders an explicit JSON null nullable as a false-keyed zero (snippets description)", () => { - const spec = goStruct([ - ["desc", goNullable(goString)], - ["name", goString], + const spec = shapeStruct([ + ["desc", shapeNullable(shapeString)], + ["name", shapeString], ]); - expect(encodeGoYaml({ desc: null, name: "x" }, spec)).toBe( + expect(encodeStructYaml({ desc: null, name: "x" }, spec)).toBe( `desc: false: "" name: x @@ -632,28 +626,28 @@ name: x }); it("renders nil and empty slices as [] and nested maps at +4 (backups list)", () => { - const spec = goStruct([ + const spec = shapeStruct([ [ "backups", - goSlice( - goStruct([ - ["id", goInt], - ["inserted_at", goString], - ["is_physical_backup", goBool], - ["status", goString], + shapeSlice( + shapeStruct([ + ["id", shapeInt], + ["inserted_at", shapeString], + ["is_physical_backup", shapeBool], + ["status", shapeString], ]), ), ], [ "physical_backup_data", - goStruct([ - ["earliest_physical_backup_date_unix", goPtr(goInt)], - ["latest_physical_backup_date_unix", goPtr(goInt)], + shapeStruct([ + ["earliest_physical_backup_date_unix", shapePtr(shapeInt)], + ["latest_physical_backup_date_unix", shapePtr(shapeInt)], ]), ], - ["pitr_enabled", goBool], - ["region", goString], - ["walg_enabled", goBool], + ["pitr_enabled", shapeBool], + ["region", shapeString], + ["walg_enabled", shapeBool], ]); const payload = { backups: [], @@ -662,7 +656,7 @@ name: x region: "us-east-1", walg_enabled: true, }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `backups: [] physicalbackupdata: earliestphysicalbackupdateunix: 1687279254 @@ -674,8 +668,8 @@ walgenabled: true ); }); - it("quotes strings exactly like yaml.v3's resolver and emitter", () => { - const spec = goMap(goString); + it("quotes strings that would resolve to a non-string YAML tag", () => { + const spec = shapeMap(shapeString); const payload = { k01: "yes", k04: "~", @@ -708,7 +702,7 @@ walgenabled: true k52: "2001-12-14 21:59:43.10 -5", k53: "2001-12-15 2:59:43.10", }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `k01: "yes" k04: "~" k07: ' leading-space' @@ -743,10 +737,13 @@ k53: "2001-12-15 2:59:43.10" ); }); - it("renders block literal chomping indicators like yaml.v3", () => { - const spec = goMap(goString); + it("renders block literal chomping indicators", () => { + const spec = shapeMap(shapeString); expect( - encodeGoYaml({ k27: "line1\nline2\n", k28: "line1\nline2\n\n", k29: "with\rcarriage" }, spec), + encodeStructYaml( + { k27: "line1\nline2\n", k28: "line1\nline2\n\n", k29: "with\rcarriage" }, + spec, + ), ).toBe( `k27: | line1 @@ -760,9 +757,11 @@ k29: "with\\rcarriage" ); }); - it("renders floats with Go's g-format exponent switch", () => { - const spec = goMap(goAny); - expect(encodeGoYaml({ f2: 1000000, f3: 78125, f4: 0.5, f5: 0.000001, f6: 1234567 }, spec)).toBe( + it("renders floats with the g-format exponent switch", () => { + const spec = shapeMap(shapeAny); + expect( + encodeStructYaml({ f2: 1000000, f3: 78125, f4: 0.5, f5: 0.000001, f6: 1234567 }, spec), + ).toBe( `f2: 1e+06 f3: 78125 f4: 0.5 @@ -772,9 +771,9 @@ f6: 1.234567e+06 ); }); - it("sorts plain map keys with yaml.v3's natural ordering", () => { - const spec = goMap(goString); - expect(encodeGoYaml({ z: "1", a: "2", "10": "3", "2": "4", B: "5", b: "6" }, spec)).toBe( + it("sorts plain map keys in natural order", () => { + const spec = shapeMap(shapeString); + expect(encodeStructYaml({ z: "1", a: "2", "10": "3", "2": "4", B: "5", b: "6" }, spec)).toBe( `"2": "4" "10": "3" B: "5" @@ -785,12 +784,12 @@ z: "1" ); }); - it("sorts unicode map keys by rune and escapes astral keys like yaml.v3", () => { - // keyList.Less compares runes, so the astral U+1F600/U+1D400 sort after U+E000/U+FF21; the - // emitter double-quotes astral characters as \U-escapes (not printable to libyaml). - const spec = goMap(goString); + it("sorts unicode map keys by code point and double-quotes astral keys with \\U escapes", () => { + // Keys compare by code point, so the astral U+1F600/U+1D400 sort after U+E000/U+FF21; the + // emitter double-quotes astral characters as \U-escapes (not printable in YAML). + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { "\u{1F600}": "emoji", "\uE000": "private-use", @@ -813,9 +812,9 @@ z: ascii }); it("validates calendar dates and zone offsets like time.Parse before quoting timestamps", () => { - const spec = goMap(goString); + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { t01: "2025-02-31", t02: "2024-02-29", @@ -849,29 +848,29 @@ t12: 1900-02-29 ); }); - it("truncates time fractions to nanoseconds like time.Time's decoder", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + it("truncates time fractions to nanoseconds", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.12345678901234Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.12345678901234Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.9999999999Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.9999999999Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.999999999Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.1Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1234567895+07:00" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1234567895+07:00" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789+07:00\n", ); }); - it("escapes non-printable scalars with \\x/\\u/\\U like yaml.v3's emitter", () => { - const spec = goMap(goString); + it("escapes non-printable scalars with \\x/\\u/\\U", () => { + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { e1: "a\uFEFFb", e2: "a\uFFFEb", @@ -892,50 +891,50 @@ e5: "nel\\Nbreak" }); }); -describe("goFieldName", () => { +describe("pascalCaseFieldName", () => { it("capitalizes snake_case tokens like oapi-codegen", () => { - expect(goFieldName("api_key")).toBe("ApiKey"); - expect(goFieldName("metadata_xml")).toBe("MetadataXml"); - expect(goFieldName("parent_project_ref")).toBe("ParentProjectRef"); - expect(goFieldName("ezbr_sha256")).toBe("EzbrSha256"); + expect(pascalCaseFieldName("api_key")).toBe("ApiKey"); + expect(pascalCaseFieldName("metadata_xml")).toBe("MetadataXml"); + expect(pascalCaseFieldName("parent_project_ref")).toBe("ParentProjectRef"); + expect(pascalCaseFieldName("ezbr_sha256")).toBe("EzbrSha256"); }); it("capitalizes the first letter of camelCase tags", () => { - expect(goFieldName("appliedSuccessfully")).toBe("AppliedSuccessfully"); - expect(goFieldName("currentConfig")).toBe("CurrentConfig"); + expect(pascalCaseFieldName("appliedSuccessfully")).toBe("AppliedSuccessfully"); + expect(pascalCaseFieldName("currentConfig")).toBe("CurrentConfig"); }); }); -describe("goFormatFloat", () => { - it("matches strconv.FormatFloat(f, 'g', -1, 64)", () => { - expect(goFormatFloat(1, 64)).toBe("1"); - expect(goFormatFloat(123456, 64)).toBe("123456"); - expect(goFormatFloat(1000000, 64)).toBe("1e+06"); - expect(goFormatFloat(1234567, 64)).toBe("1.234567e+06"); - expect(goFormatFloat(0.5, 64)).toBe("0.5"); - expect(goFormatFloat(0.000001, 64)).toBe("1e-06"); - expect(goFormatFloat(0, 64)).toBe("0"); - expect(goFormatFloat(-2.5, 64)).toBe("-2.5"); +describe("formatStructFloat", () => { + it("formats shortest round-trip digits for 64-bit floats", () => { + expect(formatStructFloat(1, 64)).toBe("1"); + expect(formatStructFloat(123456, 64)).toBe("123456"); + expect(formatStructFloat(1000000, 64)).toBe("1e+06"); + expect(formatStructFloat(1234567, 64)).toBe("1.234567e+06"); + expect(formatStructFloat(0.5, 64)).toBe("0.5"); + expect(formatStructFloat(0.000001, 64)).toBe("1e-06"); + expect(formatStructFloat(0, 64)).toBe("0"); + expect(formatStructFloat(-2.5, 64)).toBe("-2.5"); }); - it("rounds through float32 like Go's typed fields", () => { - expect(goFormatFloat(16777217, 32)).toBe("1.6777216e+07"); - expect(goFormatFloat(78125, 32)).toBe("78125"); - expect(goFormatFloat(0.5, 32)).toBe("0.5"); + it("rounds through float32 for typed fields", () => { + expect(formatStructFloat(16777217, 32)).toBe("1.6777216e+07"); + expect(formatStructFloat(78125, 32)).toBe("78125"); + expect(formatStructFloat(0.5, 32)).toBe("0.5"); }); it("breaks exact shortest-digit ties to even like Ryu, not half-up", () => { // 4249.03125 sits exactly between the two shortest 8-digit candidates, rounding to the even // final digit both ways. - expect(goFormatFloat(4249.03125, 32)).toBe("4249.0312"); - expect(goFormatFloat(4249.09375, 32)).toBe("4249.0938"); - expect(goFormatFloat(123456789, 32)).toBe("1.2345679e+08"); - expect(goFormatFloat(1048575.5, 32)).toBe("1.0485755e+06"); - expect(goFormatFloat(8388607.5, 32)).toBe("8.3886075e+06"); + expect(formatStructFloat(4249.03125, 32)).toBe("4249.0312"); + expect(formatStructFloat(4249.09375, 32)).toBe("4249.0938"); + expect(formatStructFloat(123456789, 32)).toBe("1.2345679e+08"); + expect(formatStructFloat(1048575.5, 32)).toBe("1.0485755e+06"); + expect(formatStructFloat(8388607.5, 32)).toBe("8.3886075e+06"); // Boundaries: smallest subnormal, subnormal→normal edge, and max finite. - expect(goFormatFloat(1.401298464324817e-45, 32)).toBe("1e-45"); - expect(goFormatFloat(1.1754943508222875e-38, 32)).toBe("1.1754944e-38"); - expect(goFormatFloat(3.4028234663852886e38, 32)).toBe("3.4028235e+38"); - expect(goFormatFloat(-4249.03125, 32)).toBe("-4249.0312"); + expect(formatStructFloat(1.401298464324817e-45, 32)).toBe("1e-45"); + expect(formatStructFloat(1.1754943508222875e-38, 32)).toBe("1.1754944e-38"); + expect(formatStructFloat(3.4028234663852886e38, 32)).toBe("3.4028235e+38"); + expect(formatStructFloat(-4249.03125, 32)).toBe("-4249.0312"); }); }); diff --git a/apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts b/apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts similarity index 77% rename from apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts rename to apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts index 23ec1da4e8..78bccd0c81 100644 --- a/apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts +++ b/apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from "vitest"; import openApiSpec from "@supabase/api/openapi.json"; -import { GO_BRANCH_RESPONSE } from "../commands/branches/branches.go-payload.ts"; -import { GO_ORGANIZATION_RESPONSE } from "../commands/orgs/orgs.go-payload.ts"; -import { GO_SSL_ENFORCEMENT_RESPONSE } from "../commands/ssl-enforcement/ssl-enforcement.go-payload.ts"; -import { GO_SSO_PROVIDER_RESPONSE } from "../commands/sso/sso.go-payload.ts"; -import type { GoType } from "./go-struct-output.encoders.ts"; +import { BRANCH_RESPONSE_SHAPE } from "../commands/branches/branches.response-shape.ts"; +import { ORGANIZATION_RESPONSE_SHAPE } from "../commands/orgs/orgs.response-shape.ts"; +import { SSL_ENFORCEMENT_RESPONSE_SHAPE } from "../commands/ssl-enforcement/ssl-enforcement.response-shape.ts"; +import { SSO_PROVIDER_RESPONSE_SHAPE } from "../commands/sso/sso.response-shape.ts"; +import type { OutputShape } from "./struct-output.encoders.ts"; /** - * Mechanical drift check for the `*.go-payload.ts` specs against the live OpenAPI schemas they - * mirror — field-name set, `goPtr` vs `required`, field order, and value type/kind must all + * Mechanical drift check for the `*.response-shape.ts` specs against the live OpenAPI schemas they + * mirror — field-name set, `shapePtr` vs `required`, field order, and value type/kind must all * match, or a future spec edit silently changes `-o yaml`/`-o toml` bytes instead of failing a * test. * - * oapi-codegen names its Go struct fields from the schema's JSON keys and declares them in - * alphabetical order, not the schema's own `properties` order, so field order here is compared - * against an ASCII sort of `properties` keys. That assumption holds for every schema below; - * `KNOWN_ORDER_EXCEPTIONS` records the real order for any future schema where oapi-codegen's - * Go-identifier sort diverges from a plain JSON-key sort. + * Struct fields are named from the schema's JSON keys and declared in alphabetical order, not + * the schema's own `properties` order, so field order here is compared against an ASCII sort of + * `properties` keys. That assumption holds for every schema below; `KNOWN_ORDER_EXCEPTIONS` + * records the real order for any future schema where the field-name sort diverges from a plain + * JSON-key sort. */ interface JsonSchema { @@ -51,23 +51,23 @@ function resolveSchema(schema: JsonSchema): JsonSchema { return resolved; } -interface GoStructField { +interface OutputShapeField { readonly json: string; - readonly type: GoType; + readonly type: OutputShape; } -function structFieldsOf(spec: GoType): ReadonlyArray { +function structFieldsOf(spec: OutputShape): ReadonlyArray { if (spec.kind !== "struct") { - throw new Error(`expected a struct GoType, got "${spec.kind}"`); + throw new Error(`expected a struct OutputShape, got "${spec.kind}"`); } return spec.fields; } -function isPointerType(type: GoType): boolean { +function isPointerType(type: OutputShape): boolean { return type.kind === "ptr" || type.kind === "nullable"; } -function unwrapPointer(type: GoType): GoType { +function unwrapPointer(type: OutputShape): OutputShape { return type.kind === "ptr" || type.kind === "nullable" ? type.elem : type; } @@ -79,7 +79,7 @@ interface DriftMismatch { } function compareFieldSet( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -108,15 +108,15 @@ function compareFieldSet( } function comparePointerRequired( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { const required = new Set(schema.required ?? []); const mismatches: Array = []; for (const field of fields) { - // oapi-codegen never pointer-wraps a Go map regardless of the schema's `required` list, so - // this is the one field kind the goPtr/required rule doesn't hold for. + // A map field is never pointer-wrapped regardless of the schema's `required` list, so + // this is the one field kind the shapePtr/required rule doesn't hold for. if (unwrapPointer(field.type).kind === "map") { continue; } @@ -126,8 +126,8 @@ function comparePointerRequired( mismatches.push({ path: `${path}.${field.json}`, message: isPointer - ? `spec marks "${field.json}" as goPtr, but the schema marks it required` - : `spec does not mark "${field.json}" as goPtr, but the schema marks it optional`, + ? `spec marks "${field.json}" as shapePtr, but the schema marks it required` + : `spec does not mark "${field.json}" as shapePtr, but the schema marks it optional`, }); } } @@ -135,7 +135,7 @@ function comparePointerRequired( } function compareValueType( - type: GoType, + type: OutputShape, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -162,7 +162,7 @@ function compareValueType( return [ { path, - message: `expected type=string format=date-time (Go time.Time), got type=${resolved.type ?? ""} format=${resolved.format ?? ""}`, + message: `expected type=string format=date-time (timestamp), got type=${resolved.type ?? ""} format=${resolved.format ?? ""}`, }, ]; } @@ -232,7 +232,7 @@ function compareValueType( } function compareValueTypes( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -248,7 +248,7 @@ function compareValueTypes( } function compareFieldOrder( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, schemaKey: string, path: string, @@ -269,13 +269,13 @@ function compareFieldOrder( } /** - * Walks a {@link GoType} struct spec and the corresponding OpenAPI schema in lockstep, returning + * Walks a {@link OutputShape} struct spec and the corresponding OpenAPI schema in lockstep, returning * every mismatch found. Recurses into a struct field, and into a slice field whose element is a * struct, so both `saml`'s nested fields and `domains`' array elements get the same field-set, * pointer/required, order, and value-type checks as the top-level struct. */ -function compareGoStructToSchema( - spec: GoType, +function compareShapeToSchema( + spec: OutputShape, schema: JsonSchema, schemaName: string, path: string, @@ -298,7 +298,7 @@ function compareGoStructToSchema( } if (inner.kind === "struct") { mismatches.push( - ...compareGoStructToSchema(inner, nestedSchema, schemaName, `${path}.${field.json}`), + ...compareShapeToSchema(inner, nestedSchema, schemaName, `${path}.${field.json}`), ); continue; } @@ -312,7 +312,7 @@ function compareGoStructToSchema( continue; } mismatches.push( - ...compareGoStructToSchema(elem, itemsSchema, schemaName, `${path}.${field.json}[]`), + ...compareShapeToSchema(elem, itemsSchema, schemaName, `${path}.${field.json}[]`), ); } } @@ -320,40 +320,44 @@ function compareGoStructToSchema( return mismatches; } -interface GoPayloadSpecEntry { +interface PayloadShapeEntry { readonly specName: string; - readonly spec: GoType; + readonly spec: OutputShape; readonly schemaName: string; } -const GO_PAYLOAD_SPEC_REGISTRY: ReadonlyArray = [ - { specName: "GO_BRANCH_RESPONSE", spec: GO_BRANCH_RESPONSE, schemaName: "BranchResponse_Output" }, +const PAYLOAD_SHAPE_REGISTRY: ReadonlyArray = [ { - specName: "GO_ORGANIZATION_RESPONSE", - spec: GO_ORGANIZATION_RESPONSE, + specName: "BRANCH_RESPONSE_SHAPE", + spec: BRANCH_RESPONSE_SHAPE, + schemaName: "BranchResponse_Output", + }, + { + specName: "ORGANIZATION_RESPONSE_SHAPE", + spec: ORGANIZATION_RESPONSE_SHAPE, schemaName: "OrganizationResponseV1_Output", }, { - specName: "GO_SSL_ENFORCEMENT_RESPONSE", - spec: GO_SSL_ENFORCEMENT_RESPONSE, + specName: "SSL_ENFORCEMENT_RESPONSE_SHAPE", + spec: SSL_ENFORCEMENT_RESPONSE_SHAPE, schemaName: "SslEnforcementResponse_Output", }, { - specName: "GO_SSO_PROVIDER_RESPONSE", - spec: GO_SSO_PROVIDER_RESPONSE, + specName: "SSO_PROVIDER_RESPONSE_SHAPE", + spec: SSO_PROVIDER_RESPONSE_SHAPE, schemaName: "GetProviderResponse_Output", }, ]; -describe("go-payload specs vs the OpenAPI schema (drift check)", () => { - it.each(GO_PAYLOAD_SPEC_REGISTRY)( +describe("response-shape specs vs the OpenAPI schema (drift check)", () => { + it.each(PAYLOAD_SHAPE_REGISTRY)( "$specName matches the $schemaName schema with zero drift", ({ spec, schemaName }) => { const schema = SCHEMAS[schemaName]; if (schema === undefined) { throw new Error(`missing OpenAPI schema "${schemaName}"`); } - expect(compareGoStructToSchema(spec, schema, schemaName, "$")).toEqual([]); + expect(compareShapeToSchema(spec, schema, schemaName, "$")).toEqual([]); }, ); @@ -368,8 +372,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { }, required: ["appliedSuccessfully", "currentConfig"], }; - const mismatches = compareGoStructToSchema( - GO_SSL_ENFORCEMENT_RESPONSE, + const mismatches = compareShapeToSchema( + SSL_ENFORCEMENT_RESPONSE_SHAPE, mutatedSchema, "SslEnforcementResponse", "$", @@ -382,7 +386,7 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { it("has teeth: reports a mismatch when a field's required-ness flips", () => { // A hand-mutated copy of the real BranchResponse schema with `git_branch` moved into - // `required`, though the spec still marks it `goPtr`. + // `required`, though the spec still marks it `shapePtr`. const mutatedSchema: JsonSchema = { type: "object", properties: { @@ -418,8 +422,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { "with_data", ], }; - const mismatches = compareGoStructToSchema( - GO_BRANCH_RESPONSE, + const mismatches = compareShapeToSchema( + BRANCH_RESPONSE_SHAPE, mutatedSchema, "BranchResponse", "$", @@ -427,7 +431,7 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { expect(mismatches).toContainEqual( expect.objectContaining({ path: "$.git_branch", - message: expect.stringContaining("goPtr"), + message: expect.stringContaining("shapePtr"), }), ); }); @@ -447,8 +451,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { }, required: ["appliedSuccessfully", "currentConfig"], }; - const mismatches = compareGoStructToSchema( - GO_SSL_ENFORCEMENT_RESPONSE, + const mismatches = compareShapeToSchema( + SSL_ENFORCEMENT_RESPONSE_SHAPE, mutatedSchema, "SslEnforcementResponse", "$", diff --git a/apps/cli/src/command-internal/viper-env.ts b/apps/cli/src/command-internal/supabase-env.ts similarity index 52% rename from apps/cli/src/command-internal/viper-env.ts rename to apps/cli/src/command-internal/supabase-env.ts index 47c095ccc2..477c35de5d 100644 --- a/apps/cli/src/command-internal/viper-env.ts +++ b/apps/cli/src/command-internal/supabase-env.ts @@ -6,23 +6,19 @@ * so callers OR the parsed flag value with this read (flag-set wins). */ -import { Config, Effect, Option } from "effect"; +import { Effect } from "effect"; -const VIPER_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +import { envValue } from "../shared/config/env-option.ts"; + +const TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** Truthiness for an already-resolved env value (see module doc). */ -function viperBool(raw: string | undefined): boolean { - return raw !== undefined && VIPER_TRUE.has(raw); +export function parseEnvBool(raw: string | undefined): boolean { + return raw !== undefined && TRUE_VALUES.has(raw); } -/** Env-record providers never fail this read, so a `ConfigError` here is a defect. */ -const shellEnv = (name: string) => - Config.option(Config.String(name)).pipe(Effect.map(Option.getOrUndefined), Effect.orDie); - -/** Reads a single `SUPABASE_*` boolean env var through `Config` (see module doc). */ -export const viperEnvBool = Effect.fnUntraced(function* (name: string) { - return viperBool(yield* shellEnv(name)); -}); +/** Reads a single `SUPABASE_*` boolean env var from the ambient config provider (see module doc). */ +export const supabaseEnvBool = (name: string) => Effect.map(envValue(name), parseEnvBool); /** * Resolves a `SUPABASE_*` boolean where a project `supabase/.env` value may @@ -32,25 +28,23 @@ export const viperEnvBool = Effect.fnUntraced(function* (name: string) { * project env, letting an opt-out gate default on while any present value * still disables. */ -export const viperEnvBoolWithProjectFallback = Effect.fnUntraced(function* ( +export const supabaseEnvBoolWithProjectFallback = ( name: string, projectEnv: Record, opts: { readonly whenUnset?: boolean } = {}, -) { - const raw = (yield* shellEnv(name)) ?? projectEnv[name]; - if (raw === undefined) return opts.whenUnset ?? false; - return viperBool(raw); -}); +) => + Effect.map(envValue(name), (shell) => { + const raw = shell ?? projectEnv[name]; + return raw === undefined ? (opts.whenUnset ?? false) : parseEnvBool(raw); + }); /** * Resolves a `SUPABASE_*` string with the same shell-presence-wins semantics - * as {@link viperEnvBoolWithProjectFallback}, but with no boolean coercion — + * as {@link supabaseEnvBoolWithProjectFallback}, but with no boolean coercion — * the raw merged string, or `""` when absent from both. `??` (not `||`) * encodes the presence check. */ -export const viperEnvStringWithProjectFallback = Effect.fnUntraced(function* ( +export const supabaseEnvStringWithProjectFallback = ( name: string, projectEnv: Record, -) { - return (yield* shellEnv(name)) ?? projectEnv[name] ?? ""; -}); +) => Effect.map(envValue(name), (shell) => shell ?? projectEnv[name] ?? ""); diff --git a/apps/cli/src/command-internal/viper-env.unit.test.ts b/apps/cli/src/command-internal/supabase-env.unit.test.ts similarity index 53% rename from apps/cli/src/command-internal/viper-env.unit.test.ts rename to apps/cli/src/command-internal/supabase-env.unit.test.ts index e264ccb52d..e1dc9b34b6 100644 --- a/apps/cli/src/command-internal/viper-env.unit.test.ts +++ b/apps/cli/src/command-internal/supabase-env.unit.test.ts @@ -2,24 +2,24 @@ import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect } from "effect"; import { - viperEnvBool, - viperEnvBoolWithProjectFallback, - viperEnvStringWithProjectFallback, -} from "./viper-env.ts"; + supabaseEnvBool, + supabaseEnvBoolWithProjectFallback, + supabaseEnvStringWithProjectFallback, +} from "./supabase-env.ts"; -const KEY = "SUPABASE_TEST_VIPER_BOOL"; -const STRING_KEY = "SUPABASE_TEST_VIPER_STRING"; +const KEY = "SUPABASE_TEST_ENV_BOOL"; +const STRING_KEY = "SUPABASE_TEST_ENV_STRING"; const withShellEnv = (env: Readonly>) => Effect.provide( ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), ); -describe("viperEnvBool", () => { - it.effect("is true only for strconv.ParseBool's true set (viper.GetBool parity)", () => +describe("supabaseEnvBool", () => { + it.effect("is true only for the boolean true set", () => Effect.gen(function* () { for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(yield* viperEnvBool(KEY).pipe(withShellEnv({ [KEY]: value }))).toBe(true); + expect(yield* supabaseEnvBool(KEY).pipe(withShellEnv({ [KEY]: value }))).toBe(true); } }), ); @@ -27,35 +27,35 @@ describe("viperEnvBool", () => { it.effect("is false for the false set and any unrecognized value", () => Effect.gen(function* () { for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { - expect(yield* viperEnvBool(KEY).pipe(withShellEnv({ [KEY]: value }))).toBe(false); + expect(yield* supabaseEnvBool(KEY).pipe(withShellEnv({ [KEY]: value }))).toBe(false); } }), ); it.effect("is false when the env var is absent", () => Effect.gen(function* () { - expect(yield* viperEnvBool(KEY).pipe(withShellEnv({}))).toBe(false); + expect(yield* supabaseEnvBool(KEY).pipe(withShellEnv({}))).toBe(false); }), ); }); -describe("viperEnvBoolWithProjectFallback", () => { +describe("supabaseEnvBoolWithProjectFallback", () => { it.effect("falls back to the project value only when the shell var is absent", () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe(withShellEnv({})), + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe(withShellEnv({})), ).toBe(true); expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }).pipe(withShellEnv({})), + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }).pipe(withShellEnv({})), ).toBe(false); - expect(yield* viperEnvBoolWithProjectFallback(KEY, {}).pipe(withShellEnv({}))).toBe(false); + expect(yield* supabaseEnvBoolWithProjectFallback(KEY, {}).pipe(withShellEnv({}))).toBe(false); }), ); it.effect("keeps a false shell override even when the project .env says true", () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( withShellEnv({ [KEY]: "false" }), ), ).toBe(false); @@ -65,29 +65,27 @@ describe("viperEnvBoolWithProjectFallback", () => { it.effect("treats an empty shell value as present (blocks the project value) and false", () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( withShellEnv({ [KEY]: "" }), ), ).toBe(false); }), ); - it.effect( - "treats an unparsable shell value as present and false (cast.ToBool swallows the error)", - () => - Effect.gen(function* () { - expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( - withShellEnv({ [KEY]: "banana" }), - ), - ).toBe(false); - }), + it.effect("treats an unparsable shell value as present and false", () => + Effect.gen(function* () { + expect( + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }).pipe( + withShellEnv({ [KEY]: "banana" }), + ), + ).toBe(false); + }), ); it.effect("keeps a true shell value over a false project value", () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }).pipe( withShellEnv({ [KEY]: "true" }), ), ).toBe(true); @@ -99,7 +97,7 @@ describe("viperEnvBoolWithProjectFallback", () => { () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( withShellEnv({}), ), ).toBe(true); @@ -109,47 +107,49 @@ describe("viperEnvBoolWithProjectFallback", () => { it.effect("whenUnset: true still yields false for any present non-true value", () => Effect.gen(function* () { expect( - yield* viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( withShellEnv({ [KEY]: "0" }), ), ).toBe(false); expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }, { whenUnset: true }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }, { whenUnset: true }).pipe( withShellEnv({ [KEY]: "" }), ), ).toBe(false); expect( - yield* viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( + yield* supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true }).pipe( withShellEnv({ [KEY]: "banana" }), ), ).toBe(false); expect( - yield* viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }, { whenUnset: true }).pipe( - withShellEnv({}), - ), + yield* supabaseEnvBoolWithProjectFallback( + KEY, + { [KEY]: "false" }, + { whenUnset: true }, + ).pipe(withShellEnv({})), ).toBe(false); }), ); }); -describe("viperEnvStringWithProjectFallback", () => { +describe("supabaseEnvStringWithProjectFallback", () => { it.effect("falls back to the project value only when the shell var is absent", () => Effect.gen(function* () { expect( - yield* viperEnvStringWithProjectFallback(STRING_KEY, { + yield* supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value", }).pipe(withShellEnv({})), ).toBe("project-value"); - expect(yield* viperEnvStringWithProjectFallback(STRING_KEY, {}).pipe(withShellEnv({}))).toBe( - "", - ); + expect( + yield* supabaseEnvStringWithProjectFallback(STRING_KEY, {}).pipe(withShellEnv({})), + ).toBe(""); }), ); it.effect("keeps the shell value over a project value", () => Effect.gen(function* () { expect( - yield* viperEnvStringWithProjectFallback(STRING_KEY, { + yield* supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value", }).pipe(withShellEnv({ [STRING_KEY]: "shell-value" })), ).toBe("shell-value"); @@ -159,20 +159,18 @@ describe("viperEnvStringWithProjectFallback", () => { it.effect("treats an empty shell value as present (blocks the project value)", () => Effect.gen(function* () { expect( - yield* viperEnvStringWithProjectFallback(STRING_KEY, { + yield* supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value", }).pipe(withShellEnv({ [STRING_KEY]: "" })), ).toBe(""); }), ); - it.effect( - "returns an empty string (not undefined) when absent from both, matching viper.GetString", - () => - Effect.gen(function* () { - expect( - yield* viperEnvStringWithProjectFallback(STRING_KEY, {}).pipe(withShellEnv({})), - ).toBe(""); - }), + it.effect("returns an empty string (not undefined) when absent from both", () => + Effect.gen(function* () { + expect( + yield* supabaseEnvStringWithProjectFallback(STRING_KEY, {}).pipe(withShellEnv({})), + ).toBe(""); + }), ); }); diff --git a/apps/cli/src/command-internal/target-default-warning.ts b/apps/cli/src/command-internal/target-default-warning.ts deleted file mode 100644 index 671c137dea..0000000000 --- a/apps/cli/src/command-internal/target-default-warning.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Effect, FileSystem, Option, Path } from "effect"; - -import { CommandSettings } from "../config/command-settings.service.ts"; -import { Output } from "../shared/output/output.service.ts"; -import { yellow } from "./colors.ts"; -import { readLinkSnapshotSoft } from "./link-snapshot.ts"; - -/** Warns on stderr that a run with no target selector but a configured linked project will default to the local target in the next major. */ -export const warnLinkedTargetDefault = Effect.fnUntraced(function* (input: { - readonly command: string; - readonly targetSelected: boolean; - readonly linkedRefResolved?: boolean; - readonly localTarget?: string; -}) { - if (input.targetSelected) return; - if (input.linkedRefResolved !== true) { - const cliSettings = yield* CommandSettings; - if (Option.isNone(Option.filter(cliSettings.projectId, (ref) => ref.length > 0))) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const link = yield* readLinkSnapshotSoft(fs, path, cliSettings.workdir); - if (Option.isNone(link.ref)) return; - } - } - const output = yield* Output; - const localTarget = input.localTarget ?? "the local database"; - yield* output.raw( - `${yellow( - `Warning: supabase ${input.command} will target ${localTarget} by default in the next major version. Pass --linked to keep targeting the linked project.`, - )}\n`, - "stderr", - ); -}); diff --git a/apps/cli/src/command-internal/target-default-warning.unit.test.ts b/apps/cli/src/command-internal/target-default-warning.unit.test.ts deleted file mode 100644 index d1f27d95ed..0000000000 --- a/apps/cli/src/command-internal/target-default-warning.unit.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Path } from "effect"; - -import { stripAnsi } from "../../tests/helpers/ansi.ts"; -import { VALID_REF, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; -import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { targetDefaultWarning } from "../../tests/helpers/target-default-warning.ts"; -import { writeProjectLink } from "./project-link.ts"; -import { projectStateModeLayer } from "./project-state-mode.ts"; -import { warnLinkedTargetDefault } from "./target-default-warning.ts"; - -interface Scenario { - readonly projectId?: Option.Option; - readonly refFile?: string; - readonly refPathIsDirectory?: boolean; - readonly stackLinkRef?: string; -} - -const run = (input: Parameters[0], scenario: Scenario = {}) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "target-warning-" }); - const refPath = path.join(workdir, "supabase", ".temp", "project-ref"); - if (scenario.refFile !== undefined) { - yield* fs.makeDirectory(path.dirname(refPath), { recursive: true }); - yield* fs.writeFileString(refPath, scenario.refFile); - } - if (scenario.refPathIsDirectory === true) { - yield* fs.makeDirectory(refPath, { recursive: true }); - } - if (scenario.stackLinkRef !== undefined) { - yield* writeProjectLink(workdir, { - project: { ref: scenario.stackLinkRef }, - active_branch: { ref: scenario.stackLinkRef }, - fetchedAt: "2026-01-01T00:00:00.000Z", - }); - } - const out = mockOutput(); - yield* warnLinkedTargetDefault(input).pipe( - Effect.provide( - Layer.mergeAll( - out.layer, - mockCommandSettings({ workdir, projectId: scenario.projectId ?? Option.none() }), - BunServices.layer, - projectStateModeLayer(scenario.stackLinkRef === undefined ? "legacy" : "stack"), - ), - ), - ); - return { stdout: out.stdoutText, stderr: stripAnsi(out.stderrText) }; - }).pipe(Effect.provide(BunServices.layer)); - -const BARE = { command: "db push", targetSelected: false } as const; -const WARNING = `${targetDefaultWarning("db push")}\n`; - -describe("warnLinkedTargetDefault", () => { - it.effect("warns once on stderr when SUPABASE_PROJECT_ID configures the linked project", () => - Effect.gen(function* () { - const result = yield* run(BARE, { projectId: Option.some(VALID_REF) }); - expect(result).toEqual({ stdout: "", stderr: WARNING }); - }), - ); - - it.effect("warns when only the link file configures the linked project", () => - Effect.gen(function* () { - const result = yield* run(BARE, { refFile: `${VALID_REF}\n` }); - expect(result).toEqual({ stdout: "", stderr: WARNING }); - }), - ); - - it.effect("warns when a stack-mode link record configures the linked project", () => - Effect.gen(function* () { - const result = yield* run(BARE, { stackLinkRef: VALID_REF }); - expect(result).toEqual({ stdout: "", stderr: WARNING }); - }), - ); - - it.effect("stays silent when neither SUPABASE_PROJECT_ID nor the link file is configured", () => - Effect.gen(function* () { - const result = yield* run(BARE); - expect(result).toEqual({ stdout: "", stderr: "" }); - }), - ); - - it.effect("stays silent when the link file is blank", () => - Effect.gen(function* () { - const result = yield* run(BARE, { refFile: " \n" }); - expect(result.stderr).toBe(""); - }), - ); - - it.effect("stays silent when the link file cannot be read", () => - Effect.gen(function* () { - const result = yield* run(BARE, { refPathIsDirectory: true }); - expect(result.stderr).toBe(""); - }), - ); - - it.effect("stays silent when a target selector was passed, even with a linked project", () => - Effect.gen(function* () { - const result = yield* run( - { command: "db push", targetSelected: true }, - { projectId: Option.some(VALID_REF), refFile: VALID_REF }, - ); - expect(result.stderr).toBe(""); - }), - ); - - it.effect("warns when the caller already resolved the ref and nothing is configured", () => - Effect.gen(function* () { - const result = yield* run({ ...BARE, linkedRefResolved: true }); - expect(result).toEqual({ stdout: "", stderr: WARNING }); - }), - ); - - it.effect("stays silent for a resolved ref when a target selector was passed", () => - Effect.gen(function* () { - const result = yield* run({ - command: "db push", - targetSelected: true, - linkedRefResolved: true, - }); - expect(result.stderr).toBe(""); - }), - ); - - it.effect("names the local target the caller supplies", () => - Effect.gen(function* () { - const result = yield* run( - { command: "storage ls", targetSelected: false, localTarget: "the local Storage API" }, - { projectId: Option.some(VALID_REF) }, - ); - expect(result.stderr).toBe( - `${targetDefaultWarning("storage ls", "the local Storage API")}\n`, - ); - }), - ); -}); diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts new file mode 100644 index 0000000000..893b775840 --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -0,0 +1,71 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + +/** A branch name was given but no linked parent project could be resolved to search for it. */ +export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} + +/** A linked-parent candidate (env, cache or `project-ref` file) exists but none is ref-shaped. */ +export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} + +/** No branch with the given name/UUID exists on the resolved parent project. */ +export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoundError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The branch has no `project_ref` yet (e.g. `CREATING_PROJECT`), so the lookup stops there. */ +export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ + readonly branch: string; + readonly status: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; + } +} + +/** Transport or response-decode failure while listing branches or fetching one by UUID. */ +export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchListNetworkError")<{ + readonly message: string; + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +/** `GET /v1/projects/{ref}/branches` or `GET /v1/branches/{id}` returned a non-200 status. */ +export class LinkBranchListStatusError extends Data.TaggedError("LinkBranchListStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts new file mode 100644 index 0000000000..672e86eabf --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, + LinkBranchNotReadyError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; + +describe("branch resolution actionability", () => { + it("classifies a branch-list 404 as invalid input, same policy as the api-keys 404", () => { + const result = classifyCliErrorActionability( + new LinkBranchListStatusError({ status: 404, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:not_found"); + }); + + it("keeps a non-404 branch-list status on the API-status policy", () => { + const result = classifyCliErrorActionability( + new LinkBranchListStatusError({ status: 500, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:api_status"); + }); + + it("classifies a branch-list body-decode failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LinkBranchListNetworkError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListNetworkError:api_response"); + }); + + it("classifies a branch-list transport failure as network", () => { + const result = classifyCliErrorActionability( + new LinkBranchListNetworkError({ message: "boom" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + }); + + it("classifies an invalid linked parent ref as a relink-project remediation", () => { + const result = classifyCliErrorActionability( + new LinkParentRefInvalidError({ message: "ignored" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.suggestion_type).toBe("link_project"); + expect(result.suggested_command).toBe("supabase link"); + }); + + it("classifies a not-yet-provisioned branch as an API-status problem with its own fingerprint", () => { + const result = classifyCliErrorActionability( + new LinkBranchNotReadyError({ + branch: "feature-branch", + status: "CREATING_PROJECT", + message: "ignored", + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchNotReadyError:branch_not_ready"); + }); +}); diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts new file mode 100644 index 0000000000..e81c23178f --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -0,0 +1,300 @@ +import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; +import { Effect, Option } from "effect"; + +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { block } from "../command-permissions/model.ts"; +import { + AUTHENTICATION_FAILED_STATUS_MESSAGE, + mapHttpError, + sanitizeInlineName, +} from "../http-errors.ts"; +import { + parentNotLinkedMessage, + parentRefInvalidMessage, + parentRefTypoHint, + resolveLinkedParentRef, + type ParentRefResolution, +} from "../parent-project-ref.ts"; +import { BRANCH_UUID_PATTERN } from "../ref-patterns.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkBranchNotReadyError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; +import { closestByName } from "./closest-branch.ts"; +import { stripUserinfo } from "./remote-vocabulary.ts"; + +type Branches = typeof V1ListAllBranchesOutput.Type; +export type ParentBranch = Branches[number]; + +const MAX_LISTED_BRANCHES = 20; + +/** The branch lookups every `--remote` value that names a branch makes. */ +export const branchResolution = block("branch-resolution", [ + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ selector: ["branch", "uuid"] }], + context: "a project is linked, or the value is a branch name", + }, + { + operationId: "v1-get-a-branch-config", + kind: "required", + when: [{ selector: ["uuid"] }], + context: "no linked project resolves, so the branch is looked up by its UUID", + }, + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ selector: ["bare"] }], + context: + "a bare --remote is followed by an operand that could name a branch of the linked project", + }, +]); + +/** A branch resolved to its own project ref, with the parent project it was found under. */ +export interface BranchResolution { + readonly projectRef: string; + readonly branchName: string; + readonly parentRef: string; + /** The project's production branch, which is the linked project itself. */ + readonly isDefault: boolean; +} + +function branchNotFoundMessage(value: string, parentRef: string, branches: Branches): string { + if (branches.length === 0) { + return `Branch "${value}" not found: project ${parentRef} has no branches.${parentRefTypoHint(value)}`; + } + + const sortedNames = branches.map((branch) => branch.name).toSorted(); + const shown = sortedNames.slice(0, MAX_LISTED_BRANCHES); + const remaining = sortedNames.length - shown.length; + // Branch names are API-provided; sanitize them the same way response bodies are before + // embedding them in an error message. + const shownSanitized = sanitizeInlineName(shown.join(", ")); + const namesList = + remaining > 0 + ? `${shownSanitized}, … (${remaining} more — run supabase branches list)` + : shownSanitized; + + const closest = closestByName(branches, value); + // Sanitized like the list above — an API-provided name must not be able to inject + // ANSI/OSC/newline controls into the terminal. + const didYouMean = + closest !== undefined ? ` Did you mean "${sanitizeInlineName(closest.name)}"?` : ""; + + return ( + `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}.` + + `${didYouMean}${parentRefTypoHint(value)}` + ); +} + +/** The production branch is the linked project itself. */ +export const isDefaultBranch = (branch: ParentBranch, parentRef: string): boolean => + branch.is_default || branch.project_ref === parentRef; + +/** UUIDs match case-insensitively, names case-sensitively; the not-found hint covers typos. */ +export const findBranch = (branches: Branches, value: string): ParentBranch | undefined => + branches.find( + (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), + ); + +const unresolvedParent = Effect.fnUntraced(function* ( + parent: Exclude, + value: string, +) { + return parent.kind === "absent" + ? yield* new LinkBranchNotLinkedError({ + message: parentNotLinkedMessage(stripUserinfo(value)), + }) + : yield* new LinkParentRefInvalidError({ + message: parentRefInvalidMessage(stripUserinfo(value)), + }); +}); + +/** The command's eager client, else the lazy factory's, so a bare run never resolves a token. */ +const acquireApi = Effect.fnUntraced(function* () { + const direct = yield* Effect.serviceOption(CommandPlatformApi); + if (Option.isSome(direct)) return direct.value; + const factory = yield* Effect.serviceOption(CommandPlatformApiFactory); + if (Option.isNone(factory)) { + return yield* Effect.die( + new Error("a branch lookup needs CommandPlatformApi or CommandPlatformApiFactory"), + ); + } + return yield* factory.value.make; +}); + +/** + * A branch-lookup spinner, shown only on a terminal: a non-terminal text run writes task steps to + * stdout, which may carry the command's payload. + */ +const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { + const output = yield* Output; + return output.format === "text" && output.interactive ? yield* output.task(label) : undefined; +}); + +const LIST_BRANCHES_FAILED = "failed to list branches: "; + +/** A branch-list error's transport failure, without its `failed to list branches: ` lead. */ +export const listBranchesFailureCause = (error: LinkBranchListNetworkError): string => { + const [firstLine = ""] = error.message.split("\n"); + return firstLine.startsWith(LIST_BRANCHES_FAILED) + ? firstLine.slice(LIST_BRANCHES_FAILED.length) + : firstLine; +}; + +/** What to try when the listed project is itself a preview branch, which has no branches. */ +export const previewBranchHint = (ref: string): string => + `If ${ref} is itself a preview branch, link its parent project first: supabase link --project-ref `; + +/** `GET /v1/projects/{ref}/branches` for every branch-name lookup; no `label` means no spinner. */ +const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label?: string) { + const api = yield* acquireApi(); + + const mapBranchListError = mapHttpError({ + networkError: LinkBranchListNetworkError, + statusError: LinkBranchListStatusError, + networkMessage: (cause) => `${LIST_BRANCHES_FAILED}${cause}`, + statusMessage: (status, body) => { + if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; + return status === 404 + ? `Cannot list branches for project ${parentRef} (HTTP 404). ${previewBranchHint(parentRef)}` + : `unexpected list branches status ${status}: ${body}`; + }, + }); + + const task = label === undefined ? undefined : yield* startBranchLookupTask(label); + const branches: Branches = yield* api.v1.listAllBranches({ ref: parentRef }).pipe( + Effect.tapError(() => task?.fail() ?? Effect.void), + Effect.catch(mapBranchListError), + ); + yield* task?.clear ?? Effect.void; + return branches; +}); + +export type ResolvedParent = Extract; + +/** + * The branches of the parent a lookup runs under. A cached parent stands only while it lists the + * linked project as a branch; otherwise the linked project's own branches are used, unless they + * cannot be listed, as for a deleted branch's project, which keeps the cached parent. + */ +export const listLinkedParentBranches = Effect.fnUntraced(function* ( + parent: ResolvedParent, + label?: string, +) { + const cached = yield* listParentBranches(parent.ref, label); + const { linkedRef } = parent; + if (linkedRef === undefined || cached.some((branch) => branch.project_ref === linkedRef)) { + return { parentRef: parent.ref, branches: cached }; + } + const own = yield* listParentBranches(linkedRef).pipe(Effect.option); + return Option.isSome(own) + ? { parentRef: linkedRef, branches: own.value } + : { parentRef: parent.ref, branches: cached }; +}); + +const resolveInParent = Effect.fnUntraced(function* (value: string, parent: ResolvedParent) { + const { parentRef, branches } = yield* listLinkedParentBranches(parent, "Resolving branch..."); + yield* Effect.annotateCurrentSpan({ + "project.parent_ref": parentRef, + "branch.count": branches.length, + }); + + const found = findBranch(branches, value); + if (found === undefined) { + return yield* new LinkBranchNotFoundError({ + message: branchNotFoundMessage(stripUserinfo(value), parentRef, branches), + }); + } + + if (!PROJECT_REF_PATTERN.test(found.project_ref)) { + return yield* new LinkBranchNotReadyError({ + branch: found.name, + status: found.status, + message: `Branch "${sanitizeInlineName(found.name)}" has no project ref yet (status: ${found.status}). Wait for it to finish provisioning, then retry.`, + }); + } + + return { + projectRef: found.project_ref, + branchName: found.name, + parentRef, + isDefault: isDefaultBranch(found, parentRef), + } satisfies BranchResolution; +}); + +/** + * Resolves a non-ref-shaped value to the branch's project ref by looking it up (by name or UUID) + * against the linked parent project's branches. A ref-shaped value (20 lowercase letters) is + * always a ref and never looked up as a branch name. + * + * Uses the list endpoint rather than a single-branch lookup so the full list can power the + * available-branches error. + */ +export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* (value: string) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind !== "resolved") return yield* unresolvedParent(parent, value); + return yield* resolveInParent(value, parent); +}); + +const resolveBranchById = Effect.fnUntraced(function* (id: string) { + const api = yield* acquireApi(); + const notFoundMessage = `Branch "${sanitizeInlineName(id)}" not found. Run \`supabase branches list\` to see available branches.`; + const mapBranchGetError = mapHttpError({ + networkError: LinkBranchListNetworkError, + statusError: LinkBranchListStatusError, + networkMessage: (cause) => `failed to look up branch: ${cause}`, + statusMessage: (status, body) => { + if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; + return status === 404 ? notFoundMessage : `unexpected get branch status ${status}: ${body}`; + }, + }); + + const task = yield* startBranchLookupTask("Resolving branch..."); + const detail = yield* api.v1.getABranchConfig({ branch_id_or_ref: id }).pipe( + Effect.tapError(() => task?.fail() ?? Effect.void), + Effect.catch(mapBranchGetError), + Effect.catchTag("SupabaseApiInputError", () => + Effect.fail( + new LinkBranchListStatusError({ status: 404, body: "", message: notFoundMessage }), + ), + ), + ); + yield* task?.clear ?? Effect.void; + + if (!PROJECT_REF_PATTERN.test(detail.ref)) { + return yield* new LinkBranchNotReadyError({ + branch: id, + status: detail.status, + message: `Branch "${sanitizeInlineName(id)}" has no project ref yet (status: ${detail.status}). Wait for it to finish provisioning, then retry.`, + }); + } + return { + projectRef: detail.ref, + branchName: undefined, + parentRef: undefined, + // `GET /v1/branches/{id}` does not say whether this is the production branch. + isDefault: undefined, + }; +}); + +/** + * Resolves the branch a `--remote` value names: against the linked project's branches when a + * project is linked, and a UUID with no usable link through `GET /v1/branches/{id}`. + */ +export const resolveRemoteBranchRef = Effect.fn("Target.resolveRemoteBranch")(function* ( + value: string, +) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind === "resolved") return yield* resolveInParent(value, parent); + if (BRANCH_UUID_PATTERN.test(value)) return yield* resolveBranchById(value); + return yield* unresolvedParent(parent, value); +}); diff --git a/apps/cli/src/command-internal/target/closest-branch.ts b/apps/cli/src/command-internal/target/closest-branch.ts new file mode 100644 index 0000000000..80c595c140 --- /dev/null +++ b/apps/cli/src/command-internal/target/closest-branch.ts @@ -0,0 +1,91 @@ +/** Optimal string alignment distance: an insertion, deletion, substitution or swap is one edit. */ +const editDistance = (a: string, b: string): number => { + let beforePrevious: ReadonlyArray = []; + let previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + let distance = Math.min( + (previous[column] ?? 0) + 1, + (current[column - 1] ?? 0) + 1, + (previous[column - 1] ?? 0) + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + if (row > 1 && column > 1 && a[row - 1] === b[column - 2] && a[row - 2] === b[column - 1]) { + distance = Math.min(distance, (beforePrevious[column - 2] ?? 0) + 1); + } + current[column] = distance; + } + beforePrevious = previous; + previous = current; + } + return previous[b.length] ?? 0; +}; + +/** Up to two edits, fewer for a short name, so two unrelated short words never match. */ +const allowedEdits = (a: string, b: string): number => + Math.min(2, Math.floor(Math.max(a.length, b.length) / 3)); + +const byName = (a: { readonly name: string }, b: { readonly name: string }): number => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + +/** The item named `word` ignoring case or within two edits, nearest first, then by name. */ +export const closestByName = ( + items: ReadonlyArray, + word: string, +): T | undefined => { + const target = word.trim().toLowerCase(); + let best: { readonly item: T; readonly distance: number } | undefined; + for (const item of items.toSorted(byName)) { + const name = item.name.toLowerCase(); + const distance = editDistance(target, name); + if ( + distance <= allowedEdits(target, name) && + (best === undefined || distance < best.distance) + ) { + best = { item, distance }; + } + } + return best?.item; +}; + +const MIN_NEAR_MISS_NAME_LENGTH = 5; + +/** + * The item a word most likely mistypes: one whose name matches ignoring case, else a non-default + * one of at least five characters a single edit away. A short or default name never claims a word + * that reads as an ordinary operand. + */ +export const nearMissByName = ( + items: ReadonlyArray, + word: string, + isDefault: (item: T) => boolean, +): T | undefined => { + const target = word.toLowerCase(); + const sorted = items.toSorted(byName); + return ( + sorted.find((item) => item.name.toLowerCase() === target) ?? + sorted.find( + (item) => + !isDefault(item) && + item.name.length >= MIN_NEAR_MISS_NAME_LENGTH && + editDistance(target, item.name.toLowerCase()) <= 1, + ) + ); +}; + +/** The word within `maxEdits` of `word`, ignoring case; the nearest wins, then the earlier one. */ +export const closestWord = ( + words: ReadonlyArray, + word: string, + maxEdits: number, +): string | undefined => { + const target = word.trim().toLowerCase(); + let best: { readonly word: string; readonly distance: number } | undefined; + for (const candidate of words) { + const distance = editDistance(target, candidate.toLowerCase()); + if (distance <= maxEdits && (best === undefined || distance < best.distance)) { + best = { word: candidate, distance }; + } + } + return best?.word; +}; diff --git a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts new file mode 100644 index 0000000000..89c3c7602b --- /dev/null +++ b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { closestByName, closestWord, nearMissByName } from "./closest-branch.ts"; + +const branches = ["feature-x", "staging", "Main", "add_profiles", "db"].map((name) => ({ name })); + +describe("closestByName", () => { + it.each([ + { word: "feature-y", expected: "feature-x" }, + { word: "Feature-X", expected: "feature-x" }, + { word: "stagin", expected: "staging" }, + { word: "stagign", expected: "staging" }, + { word: "mian", expected: "Main" }, + { word: "staging2", expected: "staging" }, + { word: "main", expected: "Main" }, + { word: "add_profile", expected: "add_profiles" }, + { word: " staging ", expected: "staging" }, + ])("finds $expected for $word", ({ word, expected }) => { + expect(closestByName(branches, word)?.name).toBe(expected); + }); + + it.each(["production", "feature-xyz-long", "x", "dx", "fi"])( + "finds nothing for %s, which is more than two edits from every name", + (word) => { + expect(closestByName(branches, word)).toBeUndefined(); + }, + ); + + it("lets a short word match only by case", () => { + expect(closestByName(branches, "DB")?.name).toBe("db"); + expect(closestByName(branches, "dc")).toBeUndefined(); + }); + + it("prefers the fewest edits, then the first name alphabetically", () => { + const names = [{ name: "release-b" }, { name: "release-a" }, { name: "release-ab" }]; + expect(closestByName(names, "release-c")?.name).toBe("release-a"); + expect(closestByName(names, "release-a")?.name).toBe("release-a"); + }); + + it("finds nothing among no branches", () => { + expect(closestByName([], "staging")).toBeUndefined(); + }); +}); + +describe("nearMissByName", () => { + const projectBranches = [ + { name: "main", isDefault: true }, + ...["develop", "staging", "app", "dev", "prod"].map((name) => ({ name, isDefault: false })), + ]; + const nearMiss = (word: string) => + nearMissByName(projectBranches, word, (branch) => branch.isDefault)?.name; + + it.each(["mail", "man", "rain", "api", "pro", "devel"])( + "leaves %s an ordinary word, being close only to short names, the default branch or neither", + (word) => { + expect(nearMiss(word)).toBeUndefined(); + }, + ); + + it.each([ + { word: "stagin", expected: "staging" }, + { word: "stagign", expected: "staging" }, + { word: "Dev", expected: "dev" }, + { word: "dev", expected: "dev" }, + { word: "MAIN", expected: "main" }, + ])("takes $word for $expected", ({ word, expected }) => { + expect(nearMiss(word)).toBe(expected); + }); + + it("takes the first name alphabetically when several match ignoring case", () => { + const names = [{ name: "release-b" }, { name: "Release-A" }, { name: "release-a" }]; + expect(nearMissByName(names, "RELEASE-A", () => false)?.name).toBe("Release-A"); + }); + + it("finds nothing among no branches", () => { + expect(nearMissByName([], "staging", () => false)).toBeUndefined(); + }); +}); + +describe("closestWord", () => { + const words = ["local", "migrations", "remote"]; + + it.each([ + { word: "lcoal", expected: "local" }, + { word: "LOCAL", expected: "local" }, + { word: "loc", expected: "local" }, + { word: "locall", expected: "local" }, + { word: "remove", expected: "remote" }, + { word: "migraitons", expected: "migrations" }, + { word: " remtoe ", expected: "remote" }, + ])("finds $expected for $word within two edits", ({ word, expected }) => { + expect(closestWord(words, word, 2)).toBe(expected); + }); + + it.each(["lo", "main", "feature-x", "migrate", ""])( + "finds nothing for %s, which is more than two edits from every word", + (word) => { + expect(closestWord(words, word, 2)).toBeUndefined(); + }, + ); + + it("takes the nearest word, then the earlier one", () => { + expect(closestWord(["abcd", "abce"], "abcf", 2)).toBe("abcd"); + expect(closestWord(["abxx", "abcx"], "abcd", 2)).toBe("abcx"); + }); +}); diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts new file mode 100644 index 0000000000..8d6b8a6679 --- /dev/null +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { + buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + unverifiedTargetBanner, + useTempWorkdir, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { assertBlockDrift } from "../../../tests/helpers/permission-drift.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; +import { branchesParentTarget } from "../../commands/branches/branches.target.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { resolveProjectTarget } from "./resolve-project-target.ts"; + +const CACHED_PARENT = "cachedparentprojecta"; +const LINKED_REF = "linkedprojectrefabcd"; +const OTHER_BRANCH_REF = "otherbranchrefabcdef"; +const ENV_REF = "envprojectrefaaaaaaa"; +const FLAG_REF = "flagprojectrefabcdef"; +const API = "https://api.supabase.com"; + +const tempRoot = useTempWorkdir("supabase-parent-scoped-ref-int-"); + +interface Listing { + readonly status: number; + readonly body: unknown; +} + +const parentBranch = (projectRef: string) => + branchRow(`branch-${projectRef}`, { project_ref: projectRef, parent_project_ref: CACHED_PARENT }); + +const branches = (...refs: ReadonlyArray): Listing => ({ + status: 200, + body: refs.map(parentBranch), +}); + +const failure = (status: number): Listing => ({ status, body: { message: "failed" } }); + +const listingRequest = (projectRef: string) => `GET ${API}/v1/projects/${projectRef}/branches`; + +interface LinkState { + readonly file?: string; + readonly cache?: string; + readonly env?: string; + readonly remote?: RemoteValue; +} + +interface DifferingCacheCase { + readonly name: string; + readonly listings: Readonly>; + readonly expected: { readonly ref: string; readonly requests: string[]; readonly banner: string }; +} + +interface NoCheckCase { + readonly name: string; + readonly state: LinkState; + readonly ref: string; +} + +function resolveParent(state: LinkState, listings: Readonly>) { + const out = mockOutput(); + const api = mockCommandPlatformApi({ + handler: (request) => { + const projectRef = /\/v1\/projects\/([a-z]{20})\/branches$/.exec(request.url)?.[1]; + const listing = projectRef === undefined ? undefined : listings[projectRef]; + return Effect.succeed( + listing === undefined + ? jsonResponse(request, 404, { message: "unexpected request" }) + : jsonResponse(request, listing.status, listing.body), + ); + }, + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.fromNullishOr(state.env), + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(tempRoot.current, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + if (state.file !== undefined) { + yield* fs.writeFileString(path.join(tempDir, "project-ref"), state.file); + } + if (state.cache !== undefined) { + yield* fs.writeFileString( + path.join(tempDir, "linked-project.json"), + `{"ref":"${state.cache}","name":"Parent Project","organization_id":"org_1","organization_slug":"acme"}`, + ); + } + const ref = yield* resolveProjectTarget( + { remote: Option.fromNullishOr(state.remote), projectRef: Option.none() }, + { parent: true }, + ); + assertBlockDrift(branchesParentTarget, { requests: api.requests }); + return { + ref, + requests: api.requests.map(({ method, url }) => `${method} ${url}`), + banner: out.stderrText, + }; + }).pipe(Effect.provide(layer)); +} + +describe("a parent-scoped command whose cached parent differs from the linked project", () => { + const cachedParentBanner = `Target: project Parent Project (${CACHED_PARENT}) · production\n`; + + const differingCacheCases: ReadonlyArray = [ + { + name: "keeps the cached parent while it lists the linked project as a branch", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF, LINKED_REF) }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT)], + banner: cachedParentBanner, + }, + }, + { + name: "switches to the linked project, announced without the cache's name, once the cached parent no longer lists it", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF), [LINKED_REF]: branches() }, + expected: { + ref: LINKED_REF, + requests: [listingRequest(CACHED_PARENT), listingRequest(LINKED_REF)], + banner: unverifiedTargetBanner(LINKED_REF), + }, + }, + { + name: "keeps the cached parent when the linked project's own branches cannot be listed", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF), [LINKED_REF]: failure(403) }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT), listingRequest(LINKED_REF)], + banner: cachedParentBanner, + }, + }, + { + name: "keeps the cached parent when its own branches cannot be listed", + listings: { [CACHED_PARENT]: failure(500), [LINKED_REF]: branches() }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT)], + banner: cachedParentBanner, + }, + }, + ]; + + it.effect.each(differingCacheCases)("$name", ({ listings, expected }) => + Effect.gen(function* () { + const resolved = yield* resolveParent({ file: LINKED_REF, cache: CACHED_PARENT }, listings); + expect(resolved).toEqual(expected); + }), + ); + + const noCheckCases: ReadonlyArray = [ + { + name: "there is no cache", + state: { file: LINKED_REF }, + ref: LINKED_REF, + }, + { + name: "the cache names the linked project itself", + state: { file: CACHED_PARENT, cache: CACHED_PARENT }, + ref: CACHED_PARENT, + }, + { + name: "SUPABASE_PROJECT_ID names the project", + state: { file: LINKED_REF, cache: CACHED_PARENT, env: ENV_REF }, + ref: ENV_REF, + }, + { + name: "--remote= names the project", + state: { file: LINKED_REF, cache: CACHED_PARENT, remote: { _tag: "Ref", ref: FLAG_REF } }, + ref: FLAG_REF, + }, + ]; + + it.effect.each(noCheckCases)( + "lists no branches to check the cache when $name", + ({ state, ref }) => + Effect.gen(function* () { + const resolved = yield* resolveParent(state, {}); + expect(resolved).toMatchObject({ ref, requests: [] }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts new file mode 100644 index 0000000000..27930fef6e --- /dev/null +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts @@ -0,0 +1,51 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; +import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { listLinkedParentBranches, type ResolvedParent } from "./branch-resolution.ts"; +import { describeLinkedTarget } from "./target-identity.ts"; + +/** The cache outlives a re-link, so it stands only while it lists the linked project. */ +const checkedParentRef = Effect.fnUntraced(function* (parent: ResolvedParent) { + if (parent.linkedRef === undefined) return parent.ref; + const { parentRef } = yield* listLinkedParentBranches(parent).pipe( + Effect.orElseSucceed(() => ({ parentRef: parent.ref })), + ); + yield* Effect.annotateCurrentSpan("parent_project_ref.cache_stale", parentRef !== parent.ref); + return parentRef; +}); + +/** + * The project a `branches` command acts on: the flag, else the linked parent, because branch + * management endpoints answer 403 for a branch's own ref. + */ +export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve")(function* ( + flagValue: Option.Option, +) { + const resolver = yield* ProjectRefResolver; + + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "flag"); + return yield* resolver.resolve(flagValue); + } + + const parent = yield* resolveLinkedParentRef(); + yield* Effect.annotateCurrentSpan("parent_project_ref.linked_parent", parent.kind); + if (parent.kind === "resolved") { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "linked_parent"); + const ref = yield* checkedParentRef(parent); + const cliSettings = yield* CommandSettings; + const cache = yield* readCachedLinkedProject( + yield* FileSystem.FileSystem, + yield* Path.Path, + cliSettings.workdir, + ); + yield* announceTarget(describeLinkedTarget({ ref, cache })); + return ref; + } + + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "resolver"); + return yield* resolver.resolve(Option.none()); +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts new file mode 100644 index 0000000000..3d83077875 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -0,0 +1,308 @@ +import { Effect, FileSystem, Option, Path, Result } from "effect"; + +import { AccessTokenRequiredError, InvalidAccessTokenError } from "../../auth/errors.ts"; +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { ProjectRefResolver, type ResolvedProjectRef } from "../../config/project-ref.service.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../shared/runtime/command-runtime.service.ts"; +import { yellow } from "../colors.ts"; +import { formatNamedRef, sanitizeInlineName } from "../http-errors.ts"; +import { readLinkSnapshotSoft } from "../../shared/config/link-snapshot.ts"; +import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; +import { + findBranch, + isDefaultBranch, + listBranchesFailureCause, + listLinkedParentBranches, + previewBranchHint, + resolveRemoteBranchRef, +} from "./branch-resolution.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, +} from "./branch-resolution.errors.ts"; +import { nearMissByName } from "./closest-branch.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { stripUserinfo } from "./remote-vocabulary.ts"; +import type { LinkedTarget } from "./resolve-target.ts"; +import { deprecationMessages, remoteFlagText, shellQuote } from "./target-deprecation.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; +import { describeLinkedTarget, renderBranchTarget } from "./target-identity.ts"; +import { parseTargetSelector, type TargetDeprecation } from "./target-selector.ts"; +import { TargetRemoteAmbiguousError } from "./target.errors.ts"; + +/** A `--remote` value that names a hosted project, as opposed to a database URL. */ +export type HostedRemote = Exclude; + +/** Prints a target warning on stderr in every output format, since stdout may carry the payload. */ +export const warnTarget = Effect.fnUntraced(function* (message: string) { + const output = yield* Output; + yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); +}); + +/** Prints one warning per deprecated selector flag, naming its `--remote` replacement. */ +const warnTargetDeprecations = Effect.fnUntraced(function* ( + deprecations: ReadonlyArray, +) { + if (deprecations.length === 0) return; + const { projectHost } = yield* CommandSettings; + for (const message of deprecationMessages(deprecations, projectHost)) { + yield* warnTarget(message); + } +}); + +/** + * Parses the selector flags, prints the deprecation warnings, and names the command for the + * errors that need it. + */ +export const selectTarget = Effect.fnUntraced(function* ( + flags: TargetFlagValues, + options: { readonly valueHint?: string } = {}, +) { + const runtime = yield* Effect.serviceOption(CommandRuntime); + const commandName = Option.isSome(runtime) + ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` + : "This command"; + const cliArgs = yield* Effect.serviceOption(CliArgs); + const remoteConflict = Option.getOrUndefined(cliArgs)?.remoteConflict === true; + const { selection, deprecations } = yield* Result.match( + parseTargetSelector(flags, { remoteConflict, ...options }), + { onFailure: Effect.fail, onSuccess: Effect.succeed }, + ); + yield* warnTargetDeprecations(deprecations); + return { selection, commandName }; +}); + +const linkedProjectName = Effect.fnUntraced(function* (ref: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const cache = Option.getOrUndefined(yield* readCachedLinkedProject(fs, path, workdir)); + return cache?.ref === ref ? cache.name : undefined; +}); + +const LOGIN_SUGGESTION = + "Run `supabase login` with an account that can list this project's branches."; +const SET_TOKEN_SUGGESTION = "Set SUPABASE_ACCESS_TOKEN to a valid access token."; +const FORBIDDEN_SUGGESTION = "Use an access token that can read this project's branches."; + +type TokenSource = "env" | "stored"; + +interface ListingFailure { + readonly reason: string; + readonly suggestion?: string | undefined; + readonly status?: number; + readonly authFailure?: "login" | "token"; +} + +const statusFailure = ( + status: number, + parentRef: string, + tokenSource: TokenSource, +): ListingFailure => { + const reason = `: HTTP ${status}`; + switch (status) { + case 401: + return tokenSource === "env" + ? { status, reason, suggestion: SET_TOKEN_SUGGESTION, authFailure: "token" } + : { status, reason, suggestion: LOGIN_SUGGESTION }; + case 403: + return { status, reason, suggestion: FORBIDDEN_SUGGESTION }; + case 404: + return { status, reason, suggestion: previewBranchHint(parentRef) }; + default: + return { status, reason }; + } +}; + +const listingFailure = ( + error: unknown, + parentRef: string, + tokenSource: TokenSource, +): ListingFailure => { + if (error instanceof LinkBranchListStatusError) { + return statusFailure(error.status, parentRef, tokenSource); + } + if (error instanceof LinkBranchListNetworkError) { + return { reason: `: ${sanitizeInlineName(stripUserinfo(listBranchesFailureCause(error)))}` }; + } + if (error instanceof AccessTokenRequiredError) { + return { + reason: " because no access token is set", + suggestion: LOGIN_SUGGESTION, + authFailure: "login", + }; + } + if (error instanceof InvalidAccessTokenError) { + return error.source === "env" + ? { + reason: " because SUPABASE_ACCESS_TOKEN is not a valid access token", + suggestion: SET_TOKEN_SUGGESTION, + authFailure: "token", + } + : { + reason: " because the stored access token is not valid", + suggestion: LOGIN_SUGGESTION, + authFailure: "login", + }; + } + return { reason: "" }; +}; + +const commandUsage = Effect.fnUntraced(function* () { + const runtime = yield* Effect.serviceOption(CommandRuntime); + return `supabase ${Option.isSome(runtime) ? getCommandRuntimeCommand(runtime.value) : ""}`; +}); + +const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (rawOperand: string) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind !== "resolved") return; + + const operand = stripUserinfo(rawOperand); + const usage = yield* commandUsage(); + const options = `For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: operand })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`; + + const listed = yield* listLinkedParentBranches(parent, "Checking branches...").pipe( + Effect.result, + ); + if (Result.isFailure(listed)) { + const { accessToken } = yield* CommandSettings; + const failure = listingFailure( + listed.failure, + parent.ref, + Option.isSome(accessToken) ? "env" : "stored", + ); + return yield* new TargetRemoteAmbiguousError({ + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${parent.ref} failed${failure.reason}). For the branch: ${usage} --remote=. For the linked project, put --remote last: ${usage} --remote`, + ...(failure.suggestion === undefined ? {} : { suggestion: failure.suggestion }), + ...(failure.status === undefined ? {} : { status: failure.status }), + ...(failure.authFailure === undefined ? {} : { authFailure: failure.authFailure }), + cause: listed.failure, + }); + } + const { parentRef, branches } = listed.success; + if (findBranch(branches, operand) !== undefined) { + const name = yield* linkedProjectName(parentRef); + const project = name === undefined ? `project ${parentRef}` : formatNamedRef(name, parentRef); + return yield* new TargetRemoteAmbiguousError({ + message: `"${operand}" is a branch of ${project}. ${options}`, + }); + } + const closest = nearMissByName(branches, operand, (branch) => isDefaultBranch(branch, parentRef)); + if (closest !== undefined) { + const closestName = sanitizeInlineName(closest.name); + return yield* new TargetRemoteAmbiguousError({ + message: `"${operand}" isn't a branch, but "${closestName}" is. For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: closestName })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`, + }); + } +}); + +const rejectRefShapedOperand = Effect.fnUntraced(function* (operand: string) { + const usage = yield* commandUsage(); + return yield* new TargetRemoteAmbiguousError({ + message: `"${operand}" after --remote looks like a project ref. For that project: ${usage} --remote=${operand}. To keep it as an operand, put --remote last: ${usage} --remote`, + }); +}); + +const bareRemoteWord = Effect.fnUntraced(function* () { + const cliArgs = yield* Effect.serviceOption(CliArgs); + return Option.getOrUndefined(cliArgs)?.bareRemoteOperand; +}); + +/** Fails a bare `--remote` followed by a project ref, before any prompt, connection or write. */ +export const rejectRefShapedRemote = Effect.fnUntraced(function* () { + const operand = yield* bareRemoteWord(); + if (operand !== undefined && BRANCH_PROJECT_REF_PATTERN.test(operand)) { + return yield* rejectRefShapedOperand(operand); + } +}); + +/** + * Fails a bare `--remote` whose next word is a project ref, or is, or may be, a branch of the + * linked project, before the command prompts, connects or writes. + */ +const rejectAmbiguousRemote = Effect.fnUntraced(function* () { + yield* rejectRefShapedRemote(); + const operand = yield* bareRemoteWord(); + if (operand !== undefined) yield* checkRemoteOperand(operand); +}); + +/** The linked target for a ref the ref chain has already resolved and announced. */ +const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ + kind: "linked", + ref: resolved.ref, + source: resolved.source, + matchesLinkedRef: resolved.matchesLinkedRef, + ...(resolved.poolerUrl === undefined ? {} : { poolerUrl: resolved.poolerUrl }), +}); + +const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { + const found = yield* resolveRemoteBranchRef(name); + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isSome(cell)) { + if (found.isDefault === undefined) { + yield* cell.value.announce( + describeLinkedTarget({ ref: found.projectRef, cache: Option.none() }), + ); + return found; + } + const nameOwner = found.isDefault ? found.projectRef : found.parentRef; + yield* cell.value.announce( + renderBranchTarget({ + branch: Option.fromNullishOr(found.branchName), + ref: found.projectRef, + parentRef: Option.fromNullishOr(found.parentRef), + parentName: nameOwner === undefined ? undefined : yield* linkedProjectName(nameOwner), + isDefault: found.isDefault, + }), + ); + } + return found; +}); + +/** The linked target for one hosted `--remote` value, announced as a branch when it names one. */ +export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( + remote: HostedRemote, +) { + if (remote._tag === "Branch") { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const found = yield* resolveRemoteBranch(remote.name); + const link = yield* readLinkSnapshotSoft(fs, path, workdir); + const matchesLinkedRef = Option.contains(link.ref, found.projectRef); + const poolerUrl = matchesLinkedRef + ? Option.getOrUndefined(Option.filter(link.poolerUrl, (url) => url.length > 0)) + : undefined; + return { + ...linkedTarget({ + ref: found.projectRef, + source: "flag", + matchesLinkedRef, + ...(poolerUrl === undefined ? {} : { poolerUrl }), + }), + branch: remote.name, + ...(found.parentRef === undefined ? {} : { parentRef: found.parentRef }), + ...(found.isDefault === undefined ? {} : { isDefault: found.isDefault }), + } satisfies LinkedTarget; + } + const projectRef = yield* ProjectRefResolver; + return linkedTarget( + yield* projectRef.resolveRef(remote._tag === "Ref" ? Option.some(remote.ref) : Option.none()), + ); +}); + +/** + * Resolves a hosted selection to its project: a bare `--remote` first checks that the word after + * it does not name a branch, then the value's own lookup runs. + */ +export const resolveHostedRemote = Effect.fnUntraced(function* (remote: HostedRemote) { + if (remote._tag === "Linked") yield* rejectAmbiguousRemote(); + return yield* resolveRemoteTarget(remote); +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts new file mode 100644 index 0000000000..524a7078b7 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; +import { vi } from "vitest"; + +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; +import { mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; +import { mockTty } from "../../../tests/helpers/mocks.ts"; +import { + jsonOutputLayer, + streamJsonOutputLayer, + textOutputLayer, +} from "../../shared/output/output.layer.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import type { Tty } from "../../shared/runtime/tty.service.ts"; +import { selectTarget } from "./remote-resolution.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; + +const clack = vi.hoisted(() => ({ + log: { message: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn() }, +})); + +vi.mock("@clack/prompts", () => ({ + log: clack.log, + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + spinner: vi.fn(), +})); + +const sink = (chunks: Array) => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + chunks.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ); + +const PAYLOAD = "export type Database = {}\n"; +const flags = (overrides: Partial): TargetFlagValues => ({ + remote: Option.none(), + local: false, + linked: false, + projectRef: Option.none(), + ...overrides, +}); + +const runWith = ( + outputLayer: Layer.Layer, + selector: TargetFlagValues, +) => { + const stdout: Array = []; + const stderr: Array = []; + const stdio = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => sink(stdout), + stderr: () => sink(stderr), + }), + ); + return Effect.gen(function* () { + yield* selectTarget(selector); + const output = yield* Output; + yield* output.raw(PAYLOAD); + }).pipe( + Effect.provide( + Layer.mergeAll(outputLayer, mockCommandSettings({ workdir: "/unused" })).pipe( + Layer.provide(Layer.merge(stdio, mockTty())), + ), + ), + Effect.map(() => ({ stdout: stdout.join(""), stderr: stderr.join("") })), + ); +}; + +describe("selectTarget deprecation warnings", () => { + it.effect.each([ + { format: "text", layer: textOutputLayer }, + { format: "json", layer: jsonOutputLayer }, + { format: "stream-json", layer: streamJsonOutputLayer }, + ])("leaves stdout to the payload in $format mode", ({ layer }) => + Effect.gen(function* () { + const remoteRun = yield* runWith(layer, flags({ remote: Option.some({ _tag: "Linked" }) })); + const aliasRun = yield* runWith(layer, flags({ linked: true })); + + expect(aliasRun.stdout).toBe(PAYLOAD); + expect(aliasRun.stdout).toBe(remoteRun.stdout); + expect(remoteRun.stderr).toBe(""); + expect(stripAnsi(aliasRun.stderr)).toBe( + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead\n", + ); + expect(clack.log.warn).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts new file mode 100644 index 0000000000..dc022ff778 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -0,0 +1,83 @@ +import { Result } from "effect"; + +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; +import { + DB_URL_PATTERN, + KEYWORD_DSN_PATTERN, + LINKED_LITERAL, + OFF_LITERAL, +} from "./remote-vocabulary.ts"; + +/** What `--remote` (or a `db diff --from/--to` value) names. */ +export type RemoteValue = + | { readonly _tag: "Linked" } + | { readonly _tag: "Ref"; readonly ref: string } + | { readonly _tag: "DbUrl"; readonly url: string } + | { readonly _tag: "Branch"; readonly name: string }; + +export type NamedRemoteValue = Exclude; + +/** A `://` value that is not a postgres URL; `ref` is set when it is a project's own URL. */ +interface ForeignUrlRejection { + readonly _tag: "ForeignUrl"; + readonly ref: string | undefined; +} + +/** A `host=… user=…` connection string, which `--remote` does not read as a branch name. */ +interface KeywordDsnRejection { + readonly _tag: "KeywordDsn"; +} + +export type NamedRemoteRejection = ForeignUrlRejection | KeywordDsnRejection; + +export type RemoteValueRejection = + | { readonly _tag: "Empty" } + | { readonly _tag: "Off" } + | NamedRemoteRejection; + +const PROJECT_URL_REF = /^[a-z][a-z0-9+.-]*:\/\/([a-z]{20})\.supabase\.[a-z]+(?::\d+)?(?:[/?#]|$)/; + +const refOfProjectUrl = (value: string): string | undefined => + PROJECT_URL_REF.exec(value.toLowerCase())?.[1]; + +/** A project ref, a postgres URL or a branch name; any other URL or keyword string is rejected. */ +export const parseNamedRemote = ( + raw: string, +): Result.Result => { + const value = raw.trim(); + if (BRANCH_PROJECT_REF_PATTERN.test(value)) return Result.succeed({ _tag: "Ref", ref: value }); + if (DB_URL_PATTERN.test(value)) { + return Result.succeed({ + _tag: "DbUrl", + url: value.replace(DB_URL_PATTERN, (scheme) => scheme.toLowerCase()), + }); + } + if (value.includes("://")) { + return Result.fail({ _tag: "ForeignUrl", ref: refOfProjectUrl(value) }); + } + if (KEYWORD_DSN_PATTERN.test(value)) return Result.fail({ _tag: "KeywordDsn" }); + return Result.succeed({ _tag: "Branch", name: value }); +}; + +/** Classifies a `--remote` value, or why it is rejected: empty, off, a foreign URL or a DSN. */ +export const parseRemoteValue = (raw: string): Result.Result => { + const value = raw.trim(); + if (LINKED_LITERAL.test(value)) return Result.succeed({ _tag: "Linked" }); + if (value === "") return Result.fail({ _tag: "Empty" }); + if (OFF_LITERAL.test(value)) return Result.fail({ _tag: "Off" }); + return parseNamedRemote(value); +}; + +/** {@link parseNamedRemote} for an accepted flag value; a rejection reads as a branch. */ +const classifyNamedRemote = (raw: string): NamedRemoteValue => + Result.getOrElse(parseNamedRemote(raw), (): NamedRemoteValue => ({ + _tag: "Branch", + name: raw.trim(), + })); + +/** {@link parseRemoteValue} for the same caller; `undefined` for an empty or off value. */ +export const classifyRemoteValue = (raw: string): RemoteValue | undefined => { + const value = raw.trim(); + if (value === "" || OFF_LITERAL.test(value)) return undefined; + return Result.getOrUndefined(parseRemoteValue(value)) ?? classifyNamedRemote(value); +}; diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts new file mode 100644 index 0000000000..64e50fc21f --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -0,0 +1,90 @@ +import { Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import { parseNamedRemote, parseRemoteValue } from "./remote-value.ts"; +import { couldNameBranch, stripUserinfo } from "./remote-vocabulary.ts"; + +const REF = "abcdefghijklmnopqrst"; + +describe("parseRemoteValue", () => { + it.each([ + { raw: " true ", expected: { _tag: "Linked" } }, + { raw: "YES", expected: { _tag: "Linked" } }, + { raw: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, + { raw: "Postgres://h/db", expected: { _tag: "DbUrl", url: "postgres://h/db" } }, + { raw: " POSTGRESQL://u@h/db", expected: { _tag: "DbUrl", url: "postgresql://u@h/db" } }, + { raw: " staging ", expected: { _tag: "Branch", name: "staging" } }, + ])("reads $raw as $expected", ({ raw, expected }) => { + expect(parseRemoteValue(raw)).toEqual(Result.succeed(expected)); + }); + + it.each([ + { raw: "", expected: { _tag: "Empty" } }, + { raw: " ", expected: { _tag: "Empty" } }, + { raw: "False", expected: { _tag: "Off" } }, + { raw: " 0 ", expected: { _tag: "Off" } }, + { raw: `https://${REF}.supabase.co`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: `HTTPS://${REF}.SUPABASE.CO:443/x`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: `https://${REF}.supabase.com?x=1`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: "https://db.supabase.co", expected: { _tag: "ForeignUrl", ref: undefined } }, + { raw: "ftp://example.com", expected: { _tag: "ForeignUrl", ref: undefined } }, + { raw: "host=db.example.com user=postgres", expected: { _tag: "KeywordDsn" } }, + { raw: "dbname=postgres", expected: { _tag: "KeywordDsn" } }, + { raw: "my branch", expected: { _tag: "KeywordDsn" } }, + ])("rejects $raw as $expected", ({ raw, expected }) => { + expect(parseRemoteValue(raw)).toEqual(Result.fail(expected)); + }); + + it.each(["y", "Y"])("reads %s as the linked project, not a branch", (raw) => { + expect(parseRemoteValue(raw)).toEqual(Result.succeed({ _tag: "Linked" })); + }); + + it.each(["n", "N"])("rejects %s as an off switch, not a branch", (raw) => { + expect(parseRemoteValue(raw)).toEqual(Result.fail({ _tag: "Off" })); + }); +}); + +describe("parseNamedRemote", () => { + it.each(["true", "false", "False", "YES", "off", "1", "0"])( + "treats the boolean literal %s as a branch name", + (name) => { + expect(parseNamedRemote(name)).toEqual(Result.succeed({ _tag: "Branch", name })); + }, + ); +}); + +describe("couldNameBranch", () => { + it.each(["feature-x", "feature/x", "v1.2"])("reads %s as a branch name", (word) => { + expect(couldNameBranch(word)).toBe(true); + }); + + it.each([ + "", + "STRIPE_KEY=sk_live_123", + "host=db.example.com", + "two words", + "ss:///bucket", + "123", + "./path", + ])("reads %j as something else", (word) => { + expect(couldNameBranch(word)).toBe(false); + }); +}); + +describe("stripUserinfo", () => { + it.each([ + { value: "postgresql://u:pw@h/db", expected: "postgresql://h/db" }, + { value: "postgresql://u@h/db", expected: "postgresql://h/db" }, + { value: "u:pw@staging", expected: "staging" }, + { value: "u:p@ss@h", expected: "h" }, + { value: "postgresql://u:p@ss@h/db", expected: "postgresql://h/db" }, + { value: "postgresql://u:pw@h/db?x=a@b", expected: "postgresql://h/db?x=a@b" }, + { value: "staging", expected: "staging" }, + { + value: "a message with postgresql://u:pw@h and more", + expected: "a message with postgresql://h and more", + }, + ])("turns $value into $expected", ({ value, expected }) => { + expect(stripUserinfo(value)).toBe(expected); + }); +}); diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts new file mode 100644 index 0000000000..5c41bc7158 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -0,0 +1,33 @@ +export const REMOTE_FLAG_NAME = "remote"; + +export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; + +/** The spellings of a bare `--remote`, in any case: the words the flag parser reads as true. */ +export const LINKED_LITERAL = /^(true|yes|on|1|y)$/i; + +/** The lowercase words the flag parser reads as false. */ +export const OFF_WORDS: ReadonlySet = new Set(["false", "no", "off", "0", "n"]); + +/** {@link OFF_WORDS} in any case. */ +export const OFF_LITERAL = new RegExp(`^(${[...OFF_WORDS].join("|")})$`, "i"); + +export const isBooleanLiteral = (word: string): boolean => + LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); + +/** Whitespace or `=`: a keyword connection string or `NAME=value` pair, never a branch name. */ +export const KEYWORD_DSN_PATTERN = /[\s=]/; + +const NOT_A_BRANCH_NAME: ReadonlyArray = [ + KEYWORD_DSN_PATTERN, + /^[a-z][a-z0-9+.-]*:\/\//i, + /^\d+$/, + /^[./~]/, +]; + +/** Whether a word reads as a branch name, not a path, URL, number, sentence or `NAME=value`. */ +export const couldNameBranch = (word: string): boolean => + word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); + +/** Removes `user:pass@` from a value before it appears in an error or a warning. */ +export const stripUserinfo = (value: string): string => + value.replace(/\/\/[^\s/]*@/g, "//").replace(/[^\s/@:]+:[^\s/]*@/g, ""); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts new file mode 100644 index 0000000000..389637ca53 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -0,0 +1,475 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; + +import { + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, +} from "../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; +import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import { rootCommandForFeatures } from "../../cli/root.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../shared/cli/run.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { + CommandTarget, + type CommandTargetJson, +} from "../../shared/output/command-target.service.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { resolveProjectTarget } from "./resolve-project-target.ts"; +import type { ProjectTargetFlagValues } from "./target-flags.ts"; + +const FILE_REF = "filefilefilefilefile"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; + +const fileBranch = (name: string, overrides: Parameters[1] = {}) => + branchRow(name, { parent_project_ref: FILE_REF, ...overrides }); + +const BRANCH_UUID = "11111111-2222-4333-8444-555555555555"; + +const branchConfig = (ref: string, status: string) => ({ + ref, + status, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + db_host: "h", + db_port: 5432, +}); + +const root = rootCommandForFeatures(); + +interface RunOptions { + readonly linked?: boolean; + readonly savedRef?: string; + readonly args?: ReadonlyArray; + readonly branches?: ReadonlyArray>; + readonly branchById?: { readonly status: number; readonly body: unknown }; + readonly interactive?: boolean; +} + +interface RunInput { + readonly remote?: RemoteValue; + readonly projectRef?: string; +} + +const runWith = ( + input: RunInput, + options: RunOptions, + resolve: (flags: ProjectTargetFlagValues) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "resolve-project-target-" }); + if (options.savedRef !== undefined) { + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); + } + const refFlags: Array> = []; + const resolver = Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + options.linked === false + ? Effect.fail(new ProjectRefNotLinkedError({ message: "Cannot find project ref." })) + : Effect.sync(() => { + refFlags.push(flagValue); + return { + ref: Option.getOrElse(flagValue, () => FILE_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }; + }), + resolve: () => Effect.die("resolve is not used by resolveProjectTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveProjectTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveProjectTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveProjectTarget"), + }); + const out = mockOutput( + options.interactive === undefined ? {} : { interactive: options.interactive }, + ); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url.includes("/v1/branches/")) { + const byId = options.branchById ?? { status: 404, body: { message: "not found" } }; + return Effect.succeed(jsonResponse(request, byId.status, byId.body)); + } + return Effect.succeed(jsonResponse(request, 200, options.branches ?? [])); + }, + }); + let announced: Option.Option = Option.none(); + const exit = yield* resolve({ + remote: Option.fromNullishOr(input.remote), + projectRef: Option.fromNullishOr(input.projectRef), + }).pipe( + Effect.tap(() => + CommandTarget.use((cell) => cell.json).pipe( + Effect.tap((json) => + Effect.sync(() => { + announced = json; + }), + ), + ), + ), + Effect.provide( + Layer.mergeAll( + commandTargetLayer.pipe(Layer.provide(out.layer)), + out.layer, + resolver, + api.layer, + mockCommandSettings({ workdir: dir, projectId: Option.none() }), + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunServices.layer)), + options.args === undefined + ? Layer.empty + : Layer.succeed(CliArgs, prepareCliArgs(root, options.args)), + ), + ), + Effect.exit, + ); + return { + exit, + stderr: out.stderrText, + stdout: out.stdoutText, + target: announced, + warnings: stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)), + outputWarnCalls: out.messages.filter((m) => m.type === "warn"), + progressEvents: out.progressEvents, + refFlags, + branchRequests: api.requests.length, + requestedUrls: api.requests.map((request) => request.url), + }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const run = (input: RunInput, options: RunOptions = {}) => + runWith(input, options, (flags) => resolveProjectTarget(flags)); + +const runDetailed = (input: RunInput, options: RunOptions = {}) => + runWith(input, options, (flags) => resolveProjectTarget(flags, { detail: true })); + +const successOf = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); + return exit.value; +}; + +const refOf = (exit: Exit.Exit) => successOf(exit); + +const failureOf = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + +describe("resolveProjectTarget", () => { + it.effect.each([ + { name: "no selector", input: {} }, + { name: "a bare --remote", input: { remote: { _tag: "Linked" } as const } }, + ])("falls through to the project-ref chain for $name", ({ input }) => + Effect.gen(function* () { + const result = yield* run(input); + expect(refOf(result.exit)).toBe(FILE_REF); + expect(result.refFlags).toEqual([Option.none()]); + expect(result.warnings).toEqual([]); + }), + ); + + it.effect("fails before any lookup when --remote is given twice with different values", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { + savedRef: FILE_REF, + args: ["functions", "deploy", "--remote=feature-x", `--remote=${FLAG_REF}`], + branches: [fileBranch("feature-x")], + }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: "Pass --remote once, as --remote or --remote=.", + }); + expect(result.branchRequests).toBe(0); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("uses the project --remote= names, with no deprecation warning", () => + Effect.gen(function* () { + const result = yield* run({ remote: { _tag: "Ref", ref: FLAG_REF } }); + expect(refOf(result.exit)).toBe(FLAG_REF); + expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + expect(result.warnings).toEqual([]); + expect(result.branchRequests).toBe(0); + }), + ); + + it.effect("keeps --project-ref working and warns with the --remote form of the command", () => + Effect.gen(function* () { + const result = yield* run( + { projectRef: FLAG_REF }, + { args: ["functions", "deploy", "--project-ref", FLAG_REF, "hello"] }, + ); + expect(refOf(result.exit)).toBe(FLAG_REF); + expect(result.warnings).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote= instead`, + ]); + expect(result.outputWarnCalls).toEqual([]); + expect(result.stdout).toBe(""); + }), + ); + + it.effect("looks a branch name up under the linked project and announces the branch", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.branchRequests).toBe(1); + expect(result.refFlags).toEqual([]); + expect(result.stderr).toContain(`project ${FILE_REF} · branch feature-x (${BRANCH_REF})`); + }), + ); + + it.effect("shows the branch lookup spinner on a terminal", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")], interactive: true }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.progressEvents).toContainEqual({ + type: "start", + message: "Resolving branch...", + }); + }), + ); + + it.effect("keeps the branch lookup off stdout when it is not a terminal", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")], interactive: false }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.progressEvents).toEqual([]); + expect(result.stdout).toBe(""); + }), + ); + + it.effect("treats a --project-ref that is not a ref as a branch name", () => + Effect.gen(function* () { + const result = yield* run( + { projectRef: "feature-x" }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + }), + ); + + it.effect("rejects a database URL given to --project-ref, since a project has no database", () => + Effect.gen(function* () { + const result = yield* run({ + projectRef: "postgresql://postgres:pw@db.example.com:5432/postgres", + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", + }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("stops a bare --remote followed by a word that is a branch of the linked project", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Linked" } }, + { + savedRef: FILE_REF, + args: ["functions", "deploy", "--remote", "feature-x"], + branches: [fileBranch("feature-x")], + }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-x" is a branch of project ${FILE_REF}. For the branch: supabase functions deploy --remote=feature-x. For the linked project: supabase functions deploy feature-x --remote`, + }); + }), + ); + + it.effect("carries the not-linked failure through instead of guessing a project", () => + Effect.gen(function* () { + const result = yield* run({}, { linked: false }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "ProjectRefNotLinkedError" }); + }), + ); + + it.effect( + "reads the branch list once, matching a branch UUID by id under the linked project", + () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x", { id: BRANCH_UUID })] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.requestedUrls).toEqual([ + `https://api.supabase.com/v1/projects/${FILE_REF}/branches`, + ]); + }), + ); + + it.effect( + "resolves a branch UUID through GET /v1/branches/{id} when no project is linked, as an unverified project", + () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.requestedUrls).toEqual([ + `https://api.supabase.com/v1/branches/${BRANCH_UUID}`, + ]); + expect(result.stderr).toBe(`Target: project ${BRANCH_REF}\n`); + expect(result.target).toEqual( + Option.some({ kind: "linked", project_ref: BRANCH_REF, environment: "unverified" }), + ); + }), + ); + + it.effect("reports an unknown branch UUID without a linked project", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 404, body: { message: "not found" } } }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchListStatusError", + message: expect.stringContaining(`Branch "${BRANCH_UUID}" not found`), + }); + }), + ); + + it.effect("reports a UUID-shaped value the API would reject as an unknown branch", () => + Effect.gen(function* () { + const name = "11111111-2222-3333-4444-555555555555"; + const result = yield* run({ remote: { _tag: "Branch", name } }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchListStatusError", + status: 404, + message: `Branch "${name}" not found. Run \`supabase branches list\` to see available branches.`, + }); + expect(result.requestedUrls).toEqual([]); + }), + ); + + it.effect("reports a branch UUID whose project is still provisioning", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig("", "CREATING_PROJECT") } }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotReadyError", + message: expect.stringContaining("has no project ref yet"), + }); + }), + ); + + it.effect("asks for a linked project before looking a branch name up", () => + Effect.gen(function* () { + const result = yield* run({ remote: { _tag: "Branch", name: "feature-x" } }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotLinkedError" }); + expect(result.branchRequests).toBe(0); + }), + ); +}); + +describe("resolveProjectTarget with detail", () => { + it.effect("carries the source and linked-ref match the ref chain resolved", () => + Effect.gen(function* () { + const flag = successOf((yield* runDetailed({ remote: { _tag: "Ref", ref: FLAG_REF } })).exit); + expect(flag).toEqual({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + matchesLinkedRef: false, + }); + const file = successOf((yield* runDetailed({})).exit); + expect(file).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, + }); + }), + ); + + it.effect("names the branch and the project it was found under", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, + ); + expect(successOf(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + branch: "feature-x", + parentRef: FILE_REF, + isDefault: false, + }); + }), + ); + + it.effect("marks the production branch as the default", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "main" } }, + { + savedRef: FILE_REF, + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }, + ); + expect(successOf(result.exit)).toMatchObject({ ref: FILE_REF, isDefault: true }); + }), + ); + + it.effect("matches the linked ref when the branch's own ref is the saved one", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: BRANCH_REF, branches: [fileBranch("feature-x")] }, + ); + expect(successOf(result.exit)).toMatchObject({ ref: BRANCH_REF, matchesLinkedRef: true }); + }), + ); + + it.effect("names a branch resolved from a UUID by the UUID, with no parent", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, + ); + expect(successOf(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + branch: BRANCH_UUID, + }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts new file mode 100644 index 0000000000..ee103cf65d --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -0,0 +1,102 @@ +import { Effect, Option } from "effect"; + +import { resolveParentScopedProjectRef } from "./parent-scoped-project-ref.ts"; +import { rejectRefShapedRemote, resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; +import type { ProjectTargetFlagValues } from "./target-flags.ts"; +import { TargetNeedsProjectError } from "./target.errors.ts"; + +interface ParentProjectTargetOptions { + /** For a parent-project command such as `branches *`: `--remote` takes a ref, not a branch. */ + readonly parent: true; +} + +interface DetailedProjectTargetOptions { + /** Returns the resolved linked target, with the branch it was found through, not just its ref. */ + readonly detail: true; +} + +const selectProject = Effect.fnUntraced(function* ( + flags: ProjectTargetFlagValues, + options: { readonly valueHint?: string } = {}, +) { + return yield* selectTarget( + { + remote: flags.remote, + projectRef: flags.projectRef, + local: false, + linked: false, + }, + options, + ); +}); + +const unreachableSelection = (tag: "Local" | "DbUrl") => + Effect.die(new Error(`a project-only selector cannot select "${tag}"`)); + +const resolveHosted = Effect.fn("Target.resolveProject")(function* ( + flags: ProjectTargetFlagValues, +) { + const { selection } = yield* selectProject(flags); + switch (selection._tag) { + case "Local": + case "DbUrl": + return yield* unreachableSelection(selection._tag); + case "Default": + return yield* resolveHostedRemote({ _tag: "Linked" }); + case "Linked": + case "Ref": + case "Branch": + return yield* resolveHostedRemote(selection); + } +}); + +const resolveParent = Effect.fn("Target.resolveProject")(function* ( + flags: ProjectTargetFlagValues, +) { + const { selection, commandName } = yield* selectProject(flags, { valueHint: "" }); + switch (selection._tag) { + case "Branch": + return yield* new TargetNeedsProjectError({ + message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=.`, + }); + case "Ref": + return yield* resolveParentScopedProjectRef(Option.some(selection.ref)); + case "Linked": + case "Default": + // A branch-name word has one reading here, since `--remote=` is an error; + // only a ref-shaped word reads both ways. + yield* rejectRefShapedRemote(); + return yield* resolveParentScopedProjectRef(Option.none()); + case "Local": + case "DbUrl": + return yield* unreachableSelection(selection._tag); + } +}); + +/** + * Resolves the project a hosted-only command acts on: `--remote=` names one, and a + * bare `--remote` or no selector falls through to the project-ref chain (`SUPABASE_PROJECT_ID`, + * the link file, the picker). + */ +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, +): Effect.Effect< + string, + Effect.Error>, + Effect.Services> +>; +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options: ParentProjectTargetOptions, +): ReturnType; +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options: DetailedProjectTargetOptions, +): ReturnType; +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options?: ParentProjectTargetOptions | DetailedProjectTargetOptions, +) { + if (options === undefined) return Effect.map(resolveHosted(flags), (target) => target.ref); + return "parent" in options ? resolveParent(flags) : resolveHosted(flags); +} diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts new file mode 100644 index 0000000000..0406d717f4 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -0,0 +1,1160 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Tracer, +} from "effect"; + +import { + VALID_REF, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + transportFailure, +} from "../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; +import { AccessTokenRequiredError, InvalidAccessTokenError } from "../../auth/errors.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { rootCommandForFeatures } from "../../cli/root.ts"; +import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import type { ProjectRefSource } from "../../config/project-ref.service.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../shared/cli/run.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; +import type { OutputFormat } from "../../shared/output/types.ts"; +import { + actionability, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { resolveTarget } from "./resolve-target.ts"; +import { renderLocalTarget } from "./target-identity.ts"; +import { TargetPolicy } from "./target-policy.ts"; +import type { TargetGroup } from "./target-policy.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; + +const stringifyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const none: TargetFlagValues = { + remote: Option.none(), + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const remote = (value: RemoteValue): Partial => ({ + remote: Option.some(value), +}); + +const FILE_REF = "filefilefilefilefile"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const STALE_REF = "stalestalestalestale"; +const GONE_REF = "gonegonegonegonegone"; +const SECRET_URL = + "postgresql://postgres:hunter2@db.abcdefghijklmnopqrst.supabase.co:5432/postgres"; + +const fileBranch = (name: string, overrides: Parameters[1] = {}) => + branchRow(name, { parent_project_ref: FILE_REF, ...overrides }); + +const failingListings = { + unavailable: { status: 500, message: "boom" }, + unauthorized: { status: 401, message: "Unauthorized" }, + forbidden: { status: 403, message: "Forbidden" }, + missing: { status: 404, message: "Not found" }, +} as const; + +type Listing = ReadonlyArray> | keyof typeof failingListings; + +const root = rootCommandForFeatures(); + +const projectRefResolver = (options: { readonly linked: boolean }) => { + const flags: Array> = []; + const layer = Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + options.linked + ? Effect.sync(() => { + flags.push(flagValue); + const source: ProjectRefSource = Option.isSome(flagValue) ? "flag" : "file"; + return { + ref: Option.getOrElse(flagValue, () => FILE_REF), + source, + matchesLinkedRef: !Option.isSome(flagValue), + }; + }) + : Effect.fail(new ProjectRefNotLinkedError({ message: "Cannot find project ref." })), + resolve: () => Effect.die("resolve is not used by resolveTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveTarget"), + }); + return { layer, flags }; +}; + +const run = ( + group: TargetGroup, + input: TargetFlagValues, + options: { + readonly linked?: boolean; + readonly projectId?: Option.Option; + readonly savedRef?: string; + readonly cache?: { readonly ref: string; readonly name?: string }; + readonly args?: ReadonlyArray; + readonly command?: ReadonlyArray; + readonly format?: OutputFormat; + readonly tokenFailure?: AccessTokenRequiredError | InvalidAccessTokenError; + readonly storedToken?: boolean; + readonly unreachable?: string; + readonly branches?: Listing; + readonly listings?: Readonly>; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "resolve-target-" }); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + if (options.savedRef !== undefined) { + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); + } + if (options.cache !== undefined) { + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "linked-project.json"), + stringifyJson(options.cache), + ); + } + const resolver = projectRefResolver({ linked: options.linked ?? true }); + const out = mockOutput({ format: options.format ?? "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (options.unreachable !== undefined) { + return Effect.fail(transportFailure(request, options.unreachable)); + } + const parentRef = new URL(request.url).pathname.split("/")[3] ?? ""; + const branches = options.listings?.[parentRef] ?? options.branches ?? []; + if (typeof branches === "string") { + const { status, message } = failingListings[branches]; + return Effect.succeed(jsonResponse(request, status, { message })); + } + return Effect.succeed(jsonResponse(request, 200, branches)); + }, + }); + const spans: Array = []; + const tracer = Tracer.make({ + span(spanOptions) { + const span = new Tracer.NativeSpan(spanOptions); + spans.push(span); + return span; + }, + }); + const exit = yield* resolveTarget(input).pipe( + Effect.tap(() => announceTarget(renderLocalTarget("127.0.0.1", 54322))), + Effect.provide( + Layer.mergeAll( + Layer.succeed(TargetPolicy, { group, consent: "none" }), + commandTargetLayer.pipe(Layer.provide(out.layer)), + out.layer, + resolver.layer, + options.tokenFailure === undefined + ? api.layer + : Layer.succeed(CommandPlatformApiFactory, { make: Effect.fail(options.tokenFailure) }), + mockCommandSettings({ + workdir: dir, + projectId: options.projectId ?? Option.none(), + ...(options.storedToken === true ? { accessToken: Option.none() } : {}), + }), + options.args === undefined + ? Layer.empty + : Layer.succeed(CliArgs, prepareCliArgs(root, options.args)), + options.command === undefined ? Layer.empty : commandRuntimeLayer(options.command), + ), + ), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + Effect.exit, + ); + const attributes = spans.find((span) => span.name === "Target.resolve")?.attributes; + return { + exit, + stdout: out.stdoutText, + stderr: out.stderrText, + warnings: stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)), + refFlags: resolver.flags, + apiRequests: api.requests.map( + (request) => `${request.method} ${new URL(request.url).pathname}`, + ), + kind: attributes?.get("target.kind"), + selection: attributes?.get("target.selection"), + }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const succeeded = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${stringifyJson(exit)}`); + return exit.value; +}; + +const failureOf = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + +describe("resolveTarget", () => { + it.effect("is local when nothing selects a target", () => + Effect.gen(function* () { + const result = yield* run("selectable", none); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect(result.warnings).toEqual([]); + expect([result.kind, result.selection]).toEqual(["local", "default"]); + }), + ); + + it.effect("stays local when only SUPABASE_PROJECT_ID is set", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some(VALID_REF) }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + }), + ); + + describe("--remote", () => { + it.effect("targets the linked project when bare", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, ...remote({ _tag: "Linked" }) }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, + }); + expect(result.refFlags).toEqual([Option.none()]); + expect(result.warnings).toEqual([]); + expect([result.kind, result.selection]).toEqual(["linked", "flag"]); + }), + ); + + it.effect("passes a project ref to the ref chain and marks it a flag source", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Ref", ref: FLAG_REF }), + }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + matchesLinkedRef: false, + }); + expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("keeps a database URL redacted and never resolves a project", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "DbUrl", url: SECRET_URL }), + }); + const target = succeeded(result.exit); + expect(target.kind).toBe("db-url"); + if (target.kind === "db-url") expect(Redacted.value(target.url)).toBe(SECRET_URL); + expect(stringifyJson(target)).not.toContain("hunter2"); + expect(result.refFlags).toEqual([]); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("resolves a branch name under the linked project and announces it", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: FILE_REF, + branches: [fileBranch("feature-x"), fileBranch("other", { project_ref: FLAG_REF })], + }, + ); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + branch: "feature-x", + parentRef: FILE_REF, + isDefault: false, + }); + expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); + expect(result.refFlags).toEqual([]); + expect(result.stderr).toContain(`project ${FILE_REF} · branch feature-x (${BRANCH_REF})`); + }), + ); + + it.effect("names the cached parent project in the branch banner", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [fileBranch("feature-x")], + }, + ); + expect(result.stderr).toContain( + `Target: project alpha (${FILE_REF}) · branch feature-x (${BRANCH_REF})\n`, + ); + }), + ); + + it.effect("looks a branch up under the cached parent that lists the linked project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: BRANCH_REF, + cache: { ref: FILE_REF }, + listings: { + [FILE_REF]: [fileBranch("feature-x"), fileBranch("other", { project_ref: FLAG_REF })], + }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); + expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); + }), + ); + + it.effect("ignores a cached parent that does not list the linked project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: FILE_REF, + cache: { ref: STALE_REF }, + listings: { + [STALE_REF]: [fileBranch("feature-x", { project_ref: FLAG_REF })], + [FILE_REF]: [fileBranch("feature-x")], + }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ + ref: BRANCH_REF, + branch: "feature-x", + parentRef: FILE_REF, + }); + }), + ); + + it.effect("keeps the cached parent when the linked project cannot list branches", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: GONE_REF, + cache: { ref: FILE_REF }, + listings: { [FILE_REF]: [fileBranch("feature-x")], [GONE_REF]: "missing" }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); + }), + ); + + it.effect("announces the default branch as the production project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "main" }) }, + { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.stderr).toContain(`Target: project alpha (${FILE_REF}) · production\n`); + expect(result.stderr).not.toContain("branch main"); + }), + ); + + it.effect("announces a branch whose project is the parent as production", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "main" }) }, + { savedRef: FILE_REF, branches: [fileBranch("main", { project_ref: FILE_REF })] }, + ); + expect(result.stderr).toContain(`Target: project ${FILE_REF} · production\n`); + }), + ); + + it.effect("fails with the available branches when none has that name", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "other-name" }) }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: `Branch "other-name" not found for project ${FILE_REF}. Available branches: feature-x.`, + }); + }), + ); + + it.effect("suggests the closest branch when the name is a near miss", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-y" }) }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x"), fileBranch("main")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: `Branch "feature-y" not found for project ${FILE_REF}. Available branches: feature-x, main. Did you mean "feature-x"?`, + }); + }), + ); + + it.effect("suggests the branch when two letters of the name are swapped", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "mian" }) }, + { + savedRef: FILE_REF, + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }, + ); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError" }); + expect(String((failureOf(result.exit) as Error).message)).toContain( + 'Available branches: main. Did you mean "main"?', + ); + }), + ); + + it.effect("suggests the lowercased ref when a 20-letter value has uppercase letters", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRST" }) }, + { savedRef: FILE_REF, branches: [fileBranch("main")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: `Branch "ABCDEFGHIJKLMNOPQRST" not found for project ${FILE_REF}. Available branches: main.\n If you meant a project ref: refs are exactly 20 lowercase letters (try "abcdefghijklmnopqrst").`, + }); + }), + ); + + it.effect("gives no ref hint to a mixed-case value that is not 20 letters", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRS" }) }, + { savedRef: FILE_REF, branches: [fileBranch("main")] }, + ); + expect(String((failureOf(result.exit) as Error).message)).not.toContain( + "If you meant a project ref", + ); + }), + ); + + it.effect("tells an expired token to log in instead of showing the response body", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { savedRef: FILE_REF, branches: "unauthorized" }, + ); + const error = failureOf(result.exit); + expect(error).toMatchObject({ _tag: "LinkBranchListStatusError", status: 401 }); + expect(String((error as Error).message)).toBe( + "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate.", + ); + }), + ); + + it.effect("asks to link the parent project when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Branch", name: "feature-x" }), + }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotLinkedError" }); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("suggests the lowercased ref when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRST" }), + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotLinkedError", + message: expect.stringContaining( + 'If you meant a project ref: refs are exactly 20 lowercase letters (try "abcdefghijklmnopqrst").', + ), + }); + }), + ); + + it.effect("fails with not-linked instead of falling back to local when it cannot resolve", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Linked" }) }, + { linked: false }, + ); + expect(failureOf(result.exit)).toMatchObject({ _tag: "ProjectRefNotLinkedError" }); + }), + ); + }); + + describe("a bare --remote followed by a word", () => { + const args = ["db", "pull", "--remote", "feature-x"]; + const command = ["db", "pull"]; + const bare = { ...none, ...remote({ _tag: "Linked" }) }; + const options = `For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-x --remote`; + const placeholderOptions = `For the branch: supabase db pull --remote=. For the linked project, put --remote last: supabase db pull --remote`; + + it.effect("fails when the word is a branch of the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, + }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("checks the branches of the linked project when the cached parent is stale", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + cache: { ref: STALE_REF }, + listings: { + [STALE_REF]: [fileBranch("other", { project_ref: FLAG_REF })], + [FILE_REF]: [fileBranch("feature-x")], + }, + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, + }); + }), + ); + + it.effect("resolves the linked project when the word names no branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + savedRef: FILE_REF, + branches: [fileBranch("other")], + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); + + it.effect.each([ + { name: "a project is linked", options: { savedRef: FILE_REF } }, + { name: "no project is linked", options: { linked: false } }, + ])( + "fails before any lookup when the word is shaped like a project ref and $name", + ({ options: linkState }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + ...linkState, + args: ["db", "pull", "--remote", FLAG_REF], + command, + branches: [fileBranch("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"${FLAG_REF}" after --remote looks like a project ref. For that project: supabase db pull --remote=${FLAG_REF}. To keep it as an operand, put --remote last: supabase db pull --remote`, + }); + expect(result.apiRequests).toEqual([]); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("names the branch of a linked, named project", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [fileBranch("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"feature-x" is a branch of alpha (${FILE_REF}). ${options}`, + }); + }), + ); + + it.effect("fails when the branches cannot be listed to tell, keeping the cause", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + branches: "unavailable", + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP 500). ${placeholderOptions}`, + status: 500, + cause: { _tag: "LinkBranchListStatusError", status: 500 }, + }); + expect(error).not.toHaveProperty("suggestion"); + }), + ); + + it.effect("names the transport failure when the listing never gets a response", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + unreachable: "ECONNREFUSED", + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: ECONNREFUSED). ${placeholderOptions}`, + cause: { _tag: "LinkBranchListNetworkError" }, + }); + expect(error).not.toHaveProperty("status"); + expect(error).not.toHaveProperty("suggestion"); + }), + ); + + it.effect("keeps URL credentials out of a transport failure it names", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + unreachable: + "connect ECONNREFUSED https://proxy-user:hunter2@proxy.test:8080\nsecond line", + }); + const message = String((failureOf(result.exit) as Error).message); + expect(message).toContain( + `failed: connect ECONNREFUSED https://proxy.test:8080). ${placeholderOptions}`, + ); + expect(message).not.toContain("hunter2"); + expect(message).not.toContain("second line"); + }), + ); + + it.effect.each([ + { + name: "unauthorized with a token from SUPABASE_ACCESS_TOKEN", + branches: "unauthorized", + storedToken: false, + status: 401, + suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + actionability: actionability.authToken, + }, + { + name: "unauthorized with a stored token", + branches: "unauthorized", + storedToken: true, + status: 401, + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: statusCodeActionability(401), + }, + { + name: "forbidden", + branches: "forbidden", + storedToken: false, + status: 403, + suggestion: "Use an access token that can read this project's branches.", + actionability: statusCodeActionability(403), + }, + { + name: "not found", + branches: "missing", + storedToken: false, + status: 404, + suggestion: `If ${FILE_REF} is itself a preview branch, link its parent project first: supabase link --project-ref `, + actionability: actionability.provideFlags, + }, + ] as const)("suggests the fix when the branch listing is $name", (testCase) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + branches: testCase.branches, + storedToken: testCase.storedToken, + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP ${testCase.status}). ${placeholderOptions}`, + suggestion: testCase.suggestion, + status: testCase.status, + }); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + testCase.actionability, + ); + }), + ); + + it.effect.each([ + { + name: "no access token", + failure: new AccessTokenRequiredError({ message: "Access token not provided." }), + reason: "no access token is set", + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: actionability.authLogin, + }, + { + name: "a malformed stored access token", + failure: new InvalidAccessTokenError({ message: "Invalid access token format." }), + reason: "the stored access token is not valid", + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: actionability.authLogin, + }, + { + name: "a malformed SUPABASE_ACCESS_TOKEN", + failure: new InvalidAccessTokenError({ + message: "Invalid access token format.", + source: "env", + }), + reason: "SUPABASE_ACCESS_TOKEN is not a valid access token", + suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + actionability: actionability.authToken, + }, + ])("names the cause and how to fix it when the listing has $name", (testCase) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + tokenFailure: testCase.failure, + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed because ${testCase.reason}). ${placeholderOptions}`, + suggestion: testCase.suggestion, + cause: testCase.failure, + }); + expect(error).not.toHaveProperty("status"); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + testCase.actionability, + ); + }), + ); + + it.effect.each(["text", "json"] as const)( + "never prints a NAME=value pair after --remote when the listing is forbidden (%s)", + (format) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["secrets", "set", "--remote", "STRIPE_KEY=sk_live_secret"], + command: ["secrets", "set"], + format, + savedRef: FILE_REF, + branches: "forbidden", + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.apiRequests).toEqual([]); + expect(result.stdout + result.stderr).not.toContain("sk_live_secret"); + }), + ); + + it.effect("never echoes a word it could not check against the branches", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["secrets", "unset", "--remote", "sk_live_secret"], + command: ["secrets", "unset"], + savedRef: FILE_REF, + branches: "forbidden", + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP 403). For the branch: supabase secrets unset --remote=. For the linked project, put --remote last: supabase secrets unset --remote`, + }); + expect(stringifyJson(error)).not.toContain("sk_live_secret"); + }), + ); + + it.effect("fails for a word one edit from a branch, naming the closest branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "feature-y"], + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x"), fileBranch("main")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-y" isn't a branch, but "feature-x" is. For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-y --remote`, + }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect.each([ + { name: "two edits from a branch", word: "feature-yz", branches: [fileBranch("feature-x")] }, + { name: "one edit from a short branch", word: "den", branches: [fileBranch("dev")] }, + { + name: "one edit from the default branch", + word: "mail", + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }, + ])("keeps a word that is $name as an operand", ({ word, branches }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", word], + command, + savedRef: FILE_REF, + branches, + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); + + it.effect("fails for a word with two letters swapped from a branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "stagign"], + command, + savedRef: FILE_REF, + branches: [fileBranch("staging")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"stagign" isn't a branch, but "staging" is. For the branch: supabase db pull --remote=staging. For the linked project: supabase db pull stagign --remote`, + }); + }), + ); + + it.effect("fails for a word that differs from the default branch only by case", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "Main"], + command, + savedRef: FILE_REF, + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"Main" isn't a branch, but "main" is. For the branch: supabase db pull --remote=main. For the linked project: supabase db pull Main --remote`, + }); + }), + ); + + it.effect("fails for a word that differs from a branch only by case", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "Feature-X"], + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"Feature-X" isn't a branch, but "feature-x" is. For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull Feature-X --remote`, + }); + }), + ); + + it.effect("never echoes the userinfo of a word that carries credentials", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "hunter2:pw@feature-x"], + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }); + const message = String((failureOf(result.exit) as Error).message); + expect(message).toContain(`"feature-x" is a branch`); + expect(message).not.toContain("hunter2"); + }), + ); + + it.effect.each([ + { name: "a path", args: ["storage", "ls", "--remote", "./photos"] }, + { name: "a URL", args: ["storage", "ls", "--remote", "ss:///bucket"] }, + { name: "a flag", args: ["db", "push", "--remote", "--dry-run"] }, + { name: "nothing", args: ["db", "push", "--remote"] }, + ])("does not look up branches when the next token is $name", ({ args: tokens }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: tokens, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect.each([ + { name: "seed buckets", raw: ["seed", "--remote", "buckets"] }, + { name: "db push", raw: ["db", "--remote", "push"] }, + ])("does not look up the $name subcommand as a branch", ({ raw }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: raw, + savedRef: FILE_REF, + branches: "unavailable", + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("still fails for a branch the command would otherwise take as its operand", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "feature-x"], + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, + }); + }), + ); + + it.effect("does not look up branches when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + branches: [fileBranch("feature-x")], + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.apiRequests).toEqual([]); + }), + ); + }); + + describe("a repeated --remote", () => { + const command = ["db", "push"]; + const bare = { ...none, ...remote({ _tag: "Linked" }) }; + + it.effect("fails before anything runs when the values differ", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + args: ["db", "push", "--remote=feature-x", "--remote"], + command, + savedRef: FILE_REF, + branches: [fileBranch("feature-x")], + }, + ); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: "Pass --remote once, as --remote or --remote=.", + }); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + actionability.provideFlags, + ); + expect(result.apiRequests).toEqual([]); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect.each([ + { name: "the same value", args: ["db", "push", "--remote=feature-x", "--remote=feature-x"] }, + { name: "a bare switch", args: ["db", "push", "--remote", "--remote"] }, + ])("accepts $name given twice", ({ args }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { args, command, savedRef: FILE_REF }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); + }); + + describe("deprecated selectors", () => { + it.effect( + "warns that --db-url is deprecated and offers its project ref, without echoing secrets", + () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, dbUrl: Option.some(SECRET_URL) }, + { + args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"], + command: ["db", "push"], + }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); + expect(result.warnings).toEqual([ + "--db-url is deprecated and will be removed in v4. Use --remote= instead, or --remote=abcdefghijklmnopqrst with an access token set", + ]); + expect(result.warnings.join("\n")).not.toContain("hunter2"); + }), + ); + + it.effect("does not warn for --remote itself", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, ...remote({ _tag: "Linked" }) }); + expect(result.warnings).toEqual([]); + }), + ); + }); + + it.effect("rejects conflicting selectors", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Linked" }), + linked: true, + }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetSelectorConflictError" }); + }), + ); + + describe("the default-local banner", () => { + it.effect("hints at --remote when the checkout is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for alpha (${FILE_REF})\n`, + ); + }), + ); + + it.effect("hints with the bare ref when the cache does not name the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { savedRef: FILE_REF }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("hints from SUPABASE_PROJECT_ID when nothing is linked on disk", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some(FILE_REF) }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("names the project SUPABASE_PROJECT_ID points at when the cache knows it", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some(FILE_REF), + cache: { ref: FILE_REF, name: "alpha" }, + }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for alpha (${FILE_REF})\n`, + ); + }), + ); + + it.effect("prefers SUPABASE_PROJECT_ID over the link file", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some(FILE_REF), + savedRef: FLAG_REF, + }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("adds no hint when SUPABASE_PROJECT_ID is not a project ref", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some("not-a-ref") }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect( + "adds no hint when SUPABASE_PROJECT_ID is not a project ref, even if a link exists", + () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some("not-a-ref"), + savedRef: FILE_REF, + }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("adds no hint when the checkout is not linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { cache: { ref: FILE_REF, name: "alpha" } }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("warns that --local is not needed and adds no hint, even in a linked checkout", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, local: true }, { savedRef: FILE_REF }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect(stripAnsi(result.stderr)).toBe( + "Warning: --local isn't needed (local is the default) and will be removed in v4\nTarget: local stack (127.0.0.1:54322)\n", + ); + }), + ); + }); + + it.effect.each(["local-only", "account", "hosted", "removed"] satisfies Array)( + "dies for a %s command", + (group) => + Effect.gen(function* () { + const result = yield* run(group, none); + expect(Exit.isFailure(result.exit)).toBe(true); + if (Exit.isFailure(result.exit)) { + expect(Cause.pretty(result.exit.cause)).toContain( + "resolveTarget needs a selectable command", + ); + } + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts new file mode 100644 index 0000000000..70c8554abb --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -0,0 +1,83 @@ +import { Effect, FileSystem, Option, Path, Redacted } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { PROJECT_REF_PATTERN, type ProjectRefSource } from "../../config/project-ref.service.ts"; +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { readLinkSnapshotSoft } from "../../shared/config/link-snapshot.ts"; +import { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; +import { linkedAlternativeHint } from "./target-identity.ts"; +import { TargetPolicy } from "./target-policy.ts"; + +export type ResolvedTarget = + | { readonly kind: "local" } + | { + readonly kind: "linked"; + readonly ref: string; + readonly source: ProjectRefSource; + readonly matchesLinkedRef: boolean; + /** The branch name or UUID `--remote` was given, when it named one. */ + readonly branch?: string | undefined; + /** The linked project the branch was found under, when it was looked up through one. */ + readonly parentRef?: string | undefined; + /** Whether the branch is the production branch, when its lookup said so. */ + readonly isDefault?: boolean | undefined; + /** The IPv4 pooler URL the link recorded, present only when `ref` is the linked ref. */ + readonly poolerUrl?: string | undefined; + } + | { readonly kind: "db-url"; readonly url: Redacted.Redacted }; + +export type LinkedTarget = Extract; + +type SelectedBy = "flag" | "default"; + +const hintLinkedAlternative = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir, projectId } = yield* CommandSettings; + const link = yield* readLinkSnapshotSoft(fs, path, workdir); + const linkedRef = Option.filter( + Option.orElse(projectId, () => link.ref), + (ref) => PROJECT_REF_PATTERN.test(ref), + ); + if (Option.isNone(linkedRef)) return; + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isNone(cell)) return; + yield* cell.value.hintLocal(linkedAlternativeHint({ ref: linkedRef.value, cache: link.parent })); +}); + +/** + * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a + * linked project, a branch or a database URL, and no selector means the local stack. + */ +export const resolveTarget = Effect.fn("Target.resolve")(function* (flags: TargetFlagValues) { + const policy = yield* TargetPolicy; + if (policy.group !== "selectable") { + return yield* Effect.die( + new Error(`resolveTarget needs a selectable command, not "${policy.group}"`), + ); + } + const { selection } = yield* selectTarget(flags); + + const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: SelectedBy) { + yield* Effect.annotateCurrentSpan({ + "target.kind": target.kind, + "target.selection": selectedBy, + }); + return target; + }); + + switch (selection._tag) { + case "Local": + return yield* finish({ kind: "local" }, "flag"); + case "DbUrl": + return yield* finish({ kind: "db-url", url: Redacted.make(selection.url) }, "flag"); + case "Default": + yield* hintLinkedAlternative(); + return yield* finish({ kind: "local" }, "default"); + case "Linked": + case "Ref": + case "Branch": + return yield* finish(yield* resolveHostedRemote(selection), "flag"); + } +}); diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts new file mode 100644 index 0000000000..33b1e1447a --- /dev/null +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -0,0 +1,93 @@ +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { DB_URL_PATTERN, isBooleanLiteral, stripUserinfo } from "./remote-vocabulary.ts"; +import { joinFlags, type TargetDeprecation } from "./target-selector.ts"; + +const SAFE_TOKEN = /^[\w@%+=:,./-]+$/; + +export const shellQuote = (token: string): string => + SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; + +const BRANCH_BY_REF = "--remote="; + +/** The `--remote` spelling for `value`; never echoes a database URL or a branch name's userinfo. */ +export const remoteFlagText = (value: RemoteValue): string => { + switch (value._tag) { + case "Linked": + return "--remote"; + case "Ref": + return `--remote=${value.ref}`; + case "Branch": + return isBooleanLiteral(value.name) + ? BRANCH_BY_REF + : `--remote=${shellQuote(stripUserinfo(value.name))}`; + case "DbUrl": + return "--remote="; + } +}; + +/** The `--remote` form a deprecated flag's value maps to, with a placeholder for the value. */ +const replacementOf = (value: RemoteValue): string => { + switch (value._tag) { + case "Linked": + return "--remote"; + case "Ref": + return "--remote="; + case "Branch": + return isBooleanLiteral(value.name) ? BRANCH_BY_REF : "--remote="; + case "DbUrl": + return DB_URL_PATTERN.test(value.url) ? "--remote=" : "--remote="; + } +}; + +/** The ref a Supabase connection string names: `db..` or a `postgres.` user. */ +const refFromDbUrl = (url: string, projectHost: string): string | undefined => { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (host.startsWith("db.") && host.endsWith(`.${projectHost}`)) { + const ref = host.slice("db.".length, host.length - projectHost.length - 1); + if (BRANCH_PROJECT_REF_PATTERN.test(ref)) return ref; + } + const poolerRef = /^postgres\.([a-z]{20})$/.exec(decodeURIComponent(parsed.username))?.[1]; + return poolerRef; + } catch { + return undefined; + } +}; + +/** The first sentence of every alias warning: the deprecated flags, and that v4 removes them. */ +export const deprecationLead = (flags: ReadonlyArray): string => + `${joinFlags(flags)} ${flags.length > 1 ? "are" : "is"} deprecated and will be removed in v4.`; + +export const LOCAL_NOT_NEEDED_MESSAGE = + "--local isn't needed (local is the default) and will be removed in v4"; + +const aliasMessage = ( + deprecation: Extract, + projectHost: string, +): string => { + const { flags, remote } = deprecation; + const lead = deprecationLead(flags); + + if (remote._tag === "Branch" && isBooleanLiteral(remote.name)) { + return `${lead} Reach a branch named "${remote.name}" with ${BRANCH_BY_REF} instead`; + } + if (remote._tag === "DbUrl" && !DB_URL_PATTERN.test(remote.url)) { + return `${lead} Convert the connection string to a postgresql:// URL and pass it as --remote= instead`; + } + const ref = remote._tag === "DbUrl" ? refFromDbUrl(remote.url, projectHost) : undefined; + const alternative = ref === undefined ? "" : `, or --remote=${ref} with an access token set`; + return `${lead} Use ${replacementOf(remote)} instead${alternative}`; +}; + +/** The warning per deprecated selector, naming its `--remote` replacement; no URL is echoed. */ +export const deprecationMessages = ( + deprecations: ReadonlyArray, + projectHost: string, +): ReadonlyArray => + deprecations.map((deprecation) => + deprecation.kind === "local" + ? LOCAL_NOT_NEEDED_MESSAGE + : aliasMessage(deprecation, projectHost), + ); diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts new file mode 100644 index 0000000000..10f7583426 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; +import type { TargetDeprecation } from "./target-selector.ts"; + +const REF = "abcdefghijklmnopqrst"; +const HOST = "supabase.co"; + +describe("remoteFlagText", () => { + it.each([ + { remote: { _tag: "Linked" }, expected: "--remote" }, + { remote: { _tag: "Ref", ref: REF }, expected: `--remote=${REF}` }, + { remote: { _tag: "Branch", name: "feature-x" }, expected: "--remote=feature-x" }, + { remote: { _tag: "Branch", name: "my branch" }, expected: "--remote='my branch'" }, + { remote: { _tag: "Branch", name: "u:pw@staging" }, expected: "--remote=staging" }, + { remote: { _tag: "Branch", name: "TRUE" }, expected: "--remote=" }, + { remote: { _tag: "DbUrl", url: "postgresql://u:pw@h/db" }, expected: "--remote=" }, + ] as const)("spells $remote as $expected", ({ remote, expected }) => { + expect(remoteFlagText(remote)).toBe(expected); + }); +}); + +describe("deprecationMessages", () => { + const messages = (deprecations: ReadonlyArray) => + deprecationMessages(deprecations, HOST); + + it.each(["--project-ref", "--project-id"] as const)( + "points a deprecated %s at --remote=", + (flag) => { + expect( + messages([{ kind: "remote-alias", flags: [flag], remote: { _tag: "Ref", ref: REF } }]), + ).toEqual([`${flag} is deprecated and will be removed in v4. Use --remote= instead`]); + }, + ); + + it("points a deprecated --linked at a bare --remote", () => { + expect( + messages([{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }]), + ).toEqual(["--linked is deprecated and will be removed in v4. Use --remote instead"]); + }); + + it("joins every alias the run used into one warning", () => { + expect( + messages([ + { + kind: "remote-alias", + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + }, + ]), + ).toEqual([ + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote= instead`, + ]); + }); + + it("points a deprecated --project-ref branch name at --remote=", () => { + expect( + messages([ + { + kind: "remote-alias", + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "staging" }, + }, + ]), + ).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --remote= instead", + ]); + }); + + it("offers the project ref a Supabase database URL already names, without echoing the URL", () => { + const url = `postgresql://postgres:hunter2@db.${REF}.supabase.co:5432/postgres`; + const [message] = messages([ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }, + ]); + expect(message).toBe( + `--db-url is deprecated and will be removed in v4. Use --remote= instead, or --remote=${REF} with an access token set`, + ); + expect(message).not.toContain("hunter2"); + }); + + it.each([ + { + name: "a pooler URL whose user names the project", + url: `postgresql://postgres.${REF}:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres`, + alternative: `, or --remote=${REF} with an access token set`, + }, + { + name: "a database host outside the project domain", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + alternative: "", + }, + { + name: "a loopback address", + url: "postgresql://postgres:pw@127.0.0.1:54322/postgres", + alternative: "", + }, + { + name: "a URL that does not parse", + url: "postgresql://postgres:pw@host:notaport/postgres", + alternative: "", + }, + ])("reads the project ref out of $name only when it names one", ({ url, alternative }) => { + expect( + messages([{ kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }]), + ).toEqual([ + `--db-url is deprecated and will be removed in v4. Use --remote= instead${alternative}`, + ]); + }); + + it("asks for a postgresql:// URL when --db-url held a keyword connection string", () => { + const [message] = messages([ + { + kind: "remote-alias", + flags: ["--db-url"], + remote: { _tag: "DbUrl", url: "host=db.example.com user=postgres password=hunter2" }, + }, + ]); + expect(message).toBe( + "--db-url is deprecated and will be removed in v4. Convert the connection string to a postgresql:// URL and pass it as --remote= instead", + ); + expect(message).not.toContain("hunter2"); + }); + + it.each(["true", "No", "OFF", "1", "y", "N"])( + "says a branch named %s is reached by its ref or UUID", + (name) => { + expect( + messages([ + { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Branch", name } }, + ]), + ).toEqual([ + `--project-ref is deprecated and will be removed in v4. Reach a branch named "${name}" with --remote= instead`, + ]); + }, + ); + + it("says --local is not needed", () => { + expect(messages([{ kind: "local" }])).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4", + ]); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts new file mode 100644 index 0000000000..00d318ea72 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -0,0 +1,247 @@ +import { Effect, Option, Redacted, Result } from "effect"; +import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; + +import { parseRemoteValue, type RemoteValue, type RemoteValueRejection } from "./remote-value.ts"; +import { REMOTE_FLAG_NAME } from "./remote-vocabulary.ts"; +import type { ResolvedTarget } from "./resolve-target.ts"; +import { remoteFlagText } from "./target-deprecation.ts"; + +type TargetSubject = "database" | "Storage API"; + +interface TargetFlagOptions { + readonly subject: TargetSubject; +} + +interface SelectableRemoteFlagVariant { + readonly kind: "selectable"; + readonly subject: TargetSubject; + readonly dbUrl: boolean; +} + +interface HostedRemoteFlagVariant { + readonly kind: "hosted"; + /** A command on a parent project, such as `branches *`: its value is a ref, not a branch name. */ + readonly parent: boolean; +} + +type RemoteFlagVariant = SelectableRemoteFlagVariant | HostedRemoteFlagVariant; + +const takesDbUrl = (variant: RemoteFlagVariant): boolean => + variant.kind === "selectable" && variant.dbUrl; + +/** What the variant's `--remote` takes; the help rows, flag description and errors read it. */ +const valueHintOf = (variant: RemoteFlagVariant): string => { + if (variant.kind === "hosted") return variant.parent ? "" : ""; + return variant.dbUrl ? "" : ""; +}; + +const acceptedValuesOf = (variant: RemoteFlagVariant): string => { + if (variant.kind === "hosted" && variant.parent) return "a project ref"; + const url = takesDbUrl(variant) ? "a postgres:// URL, " : ""; + return `${url}a project ref or a branch name`; +}; + +const remoteDescription = (variant: RemoteFlagVariant): string => { + const hint = valueHintOf(variant); + if (variant.kind === "hosted") { + return `Target another project: --remote=${hint}. Defaults to the linked project.`; + } + return variant.dbUrl + ? `Target a remote database: the linked project, or --remote=${hint}.` + : `Target a remote ${variant.subject}: the linked project, or --remote=${hint}.`; +}; + +const rejectionMessage = (variant: RemoteFlagVariant, rejection: RemoteValueRejection): string => { + const fallback = + variant.kind === "hosted" ? "use the linked project" : `target the local ${variant.subject}`; + switch (rejection._tag) { + case "Empty": + return `--remote= has an empty value. Pass --remote for the linked project or --remote=${valueHintOf(variant)}; leave it out to ${fallback}.`; + case "Off": + return `--remote doesn't take false. Leave it out to ${fallback}.`; + case "ForeignUrl": + return `--remote takes ${acceptedValuesOf(variant)}${ + rejection.ref === undefined + ? "." + : `; to target that project, use --remote=${rejection.ref}.` + }`; + case "KeywordDsn": + return `--remote takes ${acceptedValuesOf(variant)}${ + takesDbUrl(variant) + ? "; convert a connection string to a postgresql:// URL and pass --remote=." + : "." + }`; + } +}; + +const REMOTE_PRIMITIVE = Symbol.for("supabase/cli/RemotePrimitive"); + +/** + * Keeps the `Boolean` primitive tag so the parser accepts a bare `--remote` and never takes the + * next token as its value; only `--remote=` (or a boolean literal) reaches `parse`. A + * database URL is rejected here for a command with no database connection. + */ +const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive => + Object.assign(Object.create(Primitive.Boolean), { + [REMOTE_PRIMITIVE]: true, + parse: (raw: string) => + Result.match(parseRemoteValue(raw), { + onFailure: (rejection) => Effect.fail(rejectionMessage(variant, rejection)), + onSuccess: (value) => + value._tag === "DbUrl" && !takesDbUrl(variant) + ? Effect.fail(`--remote takes ${acceptedValuesOf(variant)}.`) + : Effect.succeed(value), + }), + }); + +/** + * Whether a flag was declared by {@link targetFlags}, {@link projectTargetFlags} or the `seed` + * globals as `--remote`. + */ +export const isRemoteFlag = (single: Param.Single): boolean => + REMOTE_PRIMITIVE in single.primitiveType; + +/** `--remote`: the linked project when bare, else the project, branch or database it names. */ +const remoteFlag = (variant: RemoteFlagVariant): Flag.Flag> => + Param.makeSingle({ + kind: Param.flagKind, + name: REMOTE_FLAG_NAME, + primitiveType: remotePrimitive(variant), + typeName: valueHintOf(variant), + }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); + +const localFlag = () => + Flag.Boolean("local").pipe( + Flag.withDescription( + "Deprecated, removed in v4: local is the default, so --local is not needed.", + ), + Flag.withDefault(false), + Flag.withHidden, + ); + +const linkedFlag = () => + Flag.Boolean("linked").pipe( + Flag.withDescription("Deprecated, removed in v4: use --remote."), + Flag.withDefault(false), + Flag.withHidden, + ); + +const dbUrlFlag = () => + Flag.String("db-url").pipe( + Flag.withDescription("Deprecated, removed in v4: use --remote=."), + Flag.optional, + Flag.withHidden, + ); + +const projectRefFlag = () => + Flag.String("project-ref").pipe( + Flag.withDescription("Deprecated, removed in v4: use --remote=."), + Flag.optional, + Flag.withHidden, + ); + +type TargetFlagDeclarations = { + readonly remote: Flag.Flag>; + readonly local: Flag.Flag; + readonly linked: Flag.Flag; + readonly projectRef: Flag.Flag>; +}; + +type DbUrlFlagDeclaration = { readonly dbUrl: Flag.Flag> }; + +/** The decoded flags every hosted command's selector is read from. */ +export interface ProjectTargetFlagValues { + readonly remote: Option.Option; + readonly projectRef: Option.Option; +} + +/** The decoded selector flags of a command that can also act on the local stack. */ +export interface TargetFlagValues extends ProjectTargetFlagValues { + readonly local: boolean; + readonly linked: boolean; + /** `gen types` only: the deprecated alias of `--project-ref`. */ + readonly projectId?: Option.Option; + /** The deprecated `--db-url`; absent on a command with no database connection. */ + readonly dbUrl?: Option.Option; +} + +/** The decoded flags of a command whose `targetFlags` declared `dbUrl: true`. */ +export interface TargetFlagValuesWithDbUrl extends TargetFlagValues { + readonly dbUrl: Option.Option; +} + +/** + * The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and + * `--project-ref` aliases for selectable commands. + */ +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: true }, +): TargetFlagDeclarations & DbUrlFlagDeclaration; +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: false }, +): TargetFlagDeclarations; +export function targetFlags(options: TargetFlagOptions & { readonly dbUrl: boolean }) { + const base = { + remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: options.dbUrl }), + local: localFlag(), + linked: linkedFlag(), + projectRef: projectRefFlag(), + }; + return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; +} + +/** + * The flags of a hosted-only command: `--remote=` and the deprecated `--project-ref` + * alias; `parent` narrows the value to a ref. + */ +export const projectTargetFlags = (options: { readonly parent?: true } = {}) => ({ + remote: remoteFlag({ kind: "hosted", parent: options.parent === true }), + projectRef: projectRefFlag(), +}); + +const remoteValueOfTarget = (target: ResolvedTarget): RemoteValue | undefined => { + switch (target.kind) { + case "local": + return undefined; + case "db-url": + return { _tag: "DbUrl", url: Redacted.value(target.url) }; + case "linked": + return target.matchesLinkedRef ? { _tag: "Linked" } : { _tag: "Ref", ref: target.ref }; + } +}; + +/** The flags that point a suggested follow-up command at `target`; a database URL is not echoed. */ +export const flagsForTarget = (target: ResolvedTarget): string => { + const value = remoteValueOfTarget(target); + return value === undefined ? "" : remoteFlagText(value); +}; + +/** + * `seed` accepts the selectors before or after its subcommand, which needs scoped globals; they + * share their definitions with {@link targetFlags}. + */ +export const seedTargetGlobalFlags = { + remote: GlobalFlag.Setting(REMOTE_FLAG_NAME)({ + flag: remoteFlag({ kind: "selectable", subject: "Storage API", dbUrl: false }), + }), + linked: GlobalFlag.Setting("linked")({ flag: linkedFlag() }), + local: GlobalFlag.Setting("local")({ flag: localFlag() }), + projectRef: GlobalFlag.Setting("project-ref")({ flag: projectRefFlag() }), +}; + +/** The `seed` selector flags a leaf reads from the group's scoped globals. */ +export const readSeedTargetFlags = Effect.fnUntraced(function* () { + return { + remote: yield* seedTargetGlobalFlags.remote, + linked: yield* seedTargetGlobalFlags.linked, + local: yield* seedTargetGlobalFlags.local, + projectRef: yield* seedTargetGlobalFlags.projectRef, + } satisfies TargetFlagValues; +}); + +/** Hidden alias of `--remote=`, accepted only by `gen types`. */ +export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( + Flag.withDescription("Deprecated, removed in v4: use --remote=."), + Flag.optional, + Flag.withHidden, +); diff --git a/apps/cli/src/command-internal/target/target-flags.unit.test.ts b/apps/cli/src/command-internal/target/target-flags.unit.test.ts new file mode 100644 index 0000000000..7a2fa878fd --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.unit.test.ts @@ -0,0 +1,391 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { CliError, CliOutput, Command, Flag } from "effect/cli"; +import type * as CliCommand from "effect/cli/Command"; + +import { formatInvalidValueMessage } from "../../shared/cli/invalid-value-message.ts"; +import { unwrapParam } from "../param-introspection.ts"; +import { + deprecatedProjectIdFlag, + flagsForTarget, + isRemoteFlag, + projectTargetFlags, + seedTargetGlobalFlags, + targetFlags, +} from "./target-flags.ts"; + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const runParse = ( + config: Config, + args: ReadonlyArray, + parsed: Array, +) => { + const command = Command.make("target-test", config).pipe( + Command.withHandler((flags) => Effect.sync(() => parsed.push(flags))), + ); + return Command.runWith(command, { version: "0.0.0-test" })(args).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter))), + ); +}; + +const parseWith = ( + config: Config, + args: ReadonlyArray, +) => { + const parsed: Array = []; + return runParse(config, args, parsed).pipe(Effect.map(() => parsed[0])); +}; + +const rejectedMessage = ( + config: Config, + args: ReadonlyArray, +) => + runParse(config, args, []).pipe( + Effect.flip, + Effect.map((error) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + throw new Error(`expected a parse failure, got ${String(error)}`); + } + const [inner] = error.errors; + if (inner?._tag !== "InvalidValue") { + throw new Error(`expected an invalid value, got ${inner?._tag}`); + } + return formatInvalidValueMessage(inner) ?? inner.message; + }), + ); + +const describeFlag = (flag: Flag.Flag) => { + const single = unwrapParam(flag)?.single; + return { + name: single?.name, + hidden: single?.hidden, + description: Option.getOrUndefined(single?.description ?? Option.none()), + }; +}; + +const REF = "abcdefghijklmnopqrst"; +const URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +describe("targetFlags", () => { + const withDbUrl = targetFlags({ subject: "database", dbUrl: true }); + + it.effect("defaults every selector to absent", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, [])).toEqual({ + remote: Option.none(), + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), + }); + }), + ); + + it.effect.each([ + { args: ["--remote"], expected: { _tag: "Linked" } }, + { args: ["--remote", "yes"], expected: { _tag: "Linked" } }, + { args: ["--remote", "y"], expected: { _tag: "Linked" } }, + { args: ["--remote=on"], expected: { _tag: "Linked" } }, + { args: ["--remote=1"], expected: { _tag: "Linked" } }, + { args: [`--remote=${REF}`], expected: { _tag: "Ref", ref: REF } }, + { args: [`--remote=${URL}`], expected: { _tag: "DbUrl", url: URL } }, + { args: ["--remote=feature-x"], expected: { _tag: "Branch", name: "feature-x" } }, + { args: ["--remote=Feature_X"], expected: { _tag: "Branch", name: "Feature_X" } }, + ])("parses $args as $expected", ({ args, expected }) => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, args)).toMatchObject({ remote: Option.some(expected) }); + }), + ); + + it.effect("keeps the deprecated aliases parseable", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); + expect(yield* parseWith(withDbUrl, ["--linked"])).toMatchObject({ linked: true }); + expect( + yield* parseWith(withDbUrl, ["--db-url", "postgresql://h/db", "--project-ref", "abc"]), + ).toMatchObject({ + dbUrl: Option.some("postgresql://h/db"), + projectRef: Option.some("abc"), + }); + expect(yield* parseWith(withDbUrl, ["--linked=false"])).toMatchObject({ linked: false }); + }), + ); + + it.effect.each([ + { args: ["--remote=false"] }, + { args: ["--remote=0"] }, + { args: ["--remote", "off"] }, + { args: ["--no-remote"] }, + ])("rejects $args by pointing at the local default", ({ args }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, args)).toBe( + "--remote doesn't take false. Leave it out to target the local database.", + ); + }), + ); + + it.effect.each([{ args: ["--remote", "n"] }, { args: ["--remote=n"] }])( + "rejects $args as the off switch, not a branch named n", + ({ args }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, args)).toBe( + "--remote doesn't take false. Leave it out to target the local database.", + ); + }), + ); + + it.effect("rejects an empty value and offers every form the command takes", () => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=; leave it out to target the local database.", + ); + }), + ); + + it.effect("names the Storage API in its local-default messages", () => + Effect.gen(function* () { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--no-remote"])).toBe( + "--remote doesn't take false. Leave it out to target the local Storage API.", + ); + expect(yield* rejectedMessage(storage, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=; leave it out to target the local Storage API.", + ); + }), + ); + + it.effect.each([ + { + value: `https://${REF}.supabase.co`, + message: `--remote takes a postgres:// URL, a project ref or a branch name; to target that project, use --remote=${REF}.`, + }, + { + value: "https://example.com/db", + message: "--remote takes a postgres:// URL, a project ref or a branch name.", + }, + ])( + "rejects $value because a URL that is not postgres:// never names a branch", + ({ value, message }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, [`--remote=${value}`])).toBe(message); + }), + ); + + it.effect("rejects a keyword connection string without echoing it", () => + Effect.gen(function* () { + const message = yield* rejectedMessage(withDbUrl, [ + "--remote=host=db.example.com user=postgres password=hunter2", + ]); + expect(message).toBe( + "--remote takes a postgres:// URL, a project ref or a branch name; convert a connection string to a postgresql:// URL and pass --remote=.", + ); + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--remote=host=db.example.com"])).toBe( + "--remote takes a project ref or a branch name.", + ); + }), + ); + + it.effect( + "leaves the postgres:// URL out of the foreign-URL message where none is accepted", + () => + Effect.gen(function* () { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--remote=https://example.com"])).toBe( + "--remote takes a project ref or a branch name.", + ); + }), + ); + + it("omits --db-url for commands without a database connection", () => { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef", "remote"]); + expect(Object.keys(withDbUrl).sort()).toEqual([ + "dbUrl", + "linked", + "local", + "projectRef", + "remote", + ]); + }); + + it.effect.each([ + { + name: "a command with no database connection", + config: targetFlags({ subject: "Storage API", dbUrl: false }), + message: "--remote takes a project ref or a branch name.", + }, + { + name: "a hosted command", + config: projectTargetFlags(), + message: "--remote takes a project ref or a branch name.", + }, + { + name: "a parent-scoped hosted command", + config: projectTargetFlags({ parent: true }), + message: "--remote takes a project ref.", + }, + { + name: "the seed group", + config: { remote: seedTargetGlobalFlags.remote.flag }, + message: "--remote takes a project ref or a branch name.", + }, + ])("rejects a database URL on $name without echoing it", ({ config, message }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(config, [`--remote=${URL}`])).toBe(message); + }), + ); + + it("shows --remote and hides every deprecated alias", () => { + expect(describeFlag(withDbUrl.remote).hidden).toBeFalsy(); + for (const alias of [ + withDbUrl.local, + withDbUrl.linked, + withDbUrl.dbUrl, + withDbUrl.projectRef, + ]) { + expect(describeFlag(alias).hidden).toBe(true); + } + }); + + it("describes --remote by what the command can target", () => { + expect(describeFlag(withDbUrl.remote).description).toBe( + "Target a remote database: the linked project, or --remote=.", + ); + expect( + describeFlag(targetFlags({ subject: "Storage API", dbUrl: false }).remote).description, + ).toBe("Target a remote Storage API: the linked project, or --remote=."); + }); +}); + +describe("projectTargetFlags", () => { + const hosted = projectTargetFlags(); + + it.effect("parses --remote and the deprecated --project-ref", () => + Effect.gen(function* () { + expect(yield* parseWith(hosted, [])).toEqual({ + remote: Option.none(), + projectRef: Option.none(), + }); + expect(yield* parseWith(hosted, [`--remote=${REF}`])).toMatchObject({ + remote: Option.some({ _tag: "Ref", ref: REF }), + }); + expect(yield* parseWith(hosted, ["--project-ref", REF])).toMatchObject({ + projectRef: Option.some(REF), + }); + }), + ); + + it.effect("rejects an off value and an empty value by pointing at the linked project", () => + Effect.gen(function* () { + expect(yield* rejectedMessage(hosted, ["--no-remote"])).toBe( + "--remote doesn't take false. Leave it out to use the linked project.", + ); + expect(yield* rejectedMessage(hosted, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=; leave it out to use the linked project.", + ); + }), + ); + + it.effect("takes only a project ref on a parent-scoped command", () => + Effect.gen(function* () { + const parent = projectTargetFlags({ parent: true }); + expect(yield* rejectedMessage(parent, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=; leave it out to use the linked project.", + ); + expect(yield* rejectedMessage(parent, ["--remote=https://example.com"])).toBe( + "--remote takes a project ref.", + ); + expect(describeFlag(parent.remote).description).toBe( + "Target another project: --remote=. Defaults to the linked project.", + ); + }), + ); + + it("describes --remote as another project and hides --project-ref", () => { + expect(describeFlag(hosted.remote).description).toBe( + "Target another project: --remote=. Defaults to the linked project.", + ); + expect(describeFlag(hosted.projectRef).hidden).toBe(true); + }); +}); + +describe("deprecatedProjectIdFlag", () => { + it("is hidden from help", () => { + expect(unwrapParam(deprecatedProjectIdFlag)?.single.hidden).toBe(true); + }); + + it.effect("parses an optional value", () => + Effect.gen(function* () { + expect(yield* parseWith({ projectId: deprecatedProjectIdFlag }, [])).toEqual({ + projectId: Option.none(), + }); + expect( + yield* parseWith({ projectId: deprecatedProjectIdFlag }, ["--project-id", "abc"]), + ).toEqual({ projectId: Option.some("abc") }); + }), + ); +}); + +describe("isRemoteFlag", () => { + const singleOf = (flag: Flag.Flag) => { + const single = unwrapParam(flag)?.single; + if (single === undefined) throw new Error("expected a single flag"); + return single; + }; + + it("recognises every declaration of --remote by its primitive, not its name", () => { + expect(isRemoteFlag(singleOf(targetFlags({ subject: "database", dbUrl: true }).remote))).toBe( + true, + ); + expect(isRemoteFlag(singleOf(projectTargetFlags().remote))).toBe(true); + expect(isRemoteFlag(singleOf(seedTargetGlobalFlags.remote.flag))).toBe(true); + }); + + it("does not take a boolean flag for --remote", () => { + expect(isRemoteFlag(singleOf(targetFlags({ subject: "database", dbUrl: true }).linked))).toBe( + false, + ); + expect(isRemoteFlag(singleOf(Flag.Boolean("remote")))).toBe(false); + }); +}); + +describe("seedTargetGlobalFlags", () => { + it("takes a ref or a branch, never a database URL", () => { + expect(describeFlag(seedTargetGlobalFlags.remote.flag).description).toBe( + "Target a remote Storage API: the linked project, or --remote=.", + ); + }); + + it("shares the selector flag names with targetFlags", () => { + expect(describeFlag(seedTargetGlobalFlags.remote.flag).name).toBe("remote"); + expect(describeFlag(seedTargetGlobalFlags.linked.flag).name).toBe("linked"); + expect(describeFlag(seedTargetGlobalFlags.local.flag).name).toBe("local"); + }); +}); + +describe("flagsForTarget", () => { + it("names the flags that re-select each kind of target without echoing a db-url", () => { + expect(flagsForTarget({ kind: "local" })).toBe(""); + expect(flagsForTarget({ kind: "db-url", url: Redacted.make("postgresql://u:pw@h/db") })).toBe( + "--remote=", + ); + expect( + flagsForTarget({ kind: "linked", ref: REF, source: "file", matchesLinkedRef: true }), + ).toBe("--remote"); + }); + + it("keeps an explicit ref that is not the linked project", () => { + expect( + flagsForTarget({ kind: "linked", ref: REF, source: "flag", matchesLinkedRef: false }), + ).toBe(`--remote=${REF}`); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts new file mode 100644 index 0000000000..392b4f21cb --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -0,0 +1,119 @@ +import { Option } from "effect"; + +import type { RenderedTarget } from "../../shared/output/command-target.service.ts"; +import { sanitizeInlineName } from "../http-errors.ts"; +import type { CachedLinkedProject } from "../../shared/config/link-snapshot.ts"; + +const hostWithPort = (host: string, port: number): string => + `${host.includes(":") ? `[${host}]` : host}:${port}`; + +export const renderLocalTarget = (host: string, port: number): RenderedTarget => ({ + text: `local stack (${hostWithPort(sanitizeInlineName(host), port)})`, + json: { kind: "local", host, port }, +}); + +/** Names the host and port only; the connection string's credentials never reach the banner. */ +export const renderDbUrlTarget = (host: string, port: number): RenderedTarget => ({ + text: `custom Postgres (${hostWithPort(sanitizeInlineName(host), port)})`, + json: { kind: "db-url", host, port }, +}); + +const named = (name: string | undefined, ref: string): string => { + const safeName = name === undefined ? "" : sanitizeInlineName(name); + return safeName.length === 0 ? ref : `${safeName} (${ref})`; +}; + +const productionTarget = (ref: string, name: string | undefined): RenderedTarget => ({ + text: `project ${named(name, ref)} · production`, + json: { + kind: "linked", + project_ref: ref, + ...(name === undefined ? {} : { project_name: name }), + environment: "production", + }, +}); + +/** + * A linked project's identity from the link's recorded parent alone, with no API call. Only a + * cache naming the resolved ref itself is evidence (a production project); a project that is + * merely linked, or one the cache cannot vouch for, is shown as its ref and reported `unverified`, + * because the cache can be stale or missing and never proves a branch. A ref that differs from + * the linked project's own says so. + */ +export const describeLinkedTarget = (input: { + readonly ref: string; + readonly cache: Option.Option; + /** The name the project picker showed for `ref`. */ + readonly name?: string; + /** The linked ref, when the caller compares against the linked project. */ + readonly linkedRef?: Option.Option; +}): RenderedTarget => { + const { ref } = input; + const cached = Option.getOrUndefined(input.cache); + if (cached?.ref === ref) return productionTarget(ref, cached.name); + const differsFromLinked = Option.exists( + input.linkedRef ?? Option.none(), + (linked) => linked !== ref, + ); + return { + text: `project ${named(input.name, ref)}${differsFromLinked ? " (not the linked project)" : ""}`, + json: { + kind: "linked", + project_ref: ref, + ...(input.name === undefined ? {} : { project_name: input.name }), + environment: "unverified", + }, + }; +}; + +/** The suffix of a default-local banner when linked, naming the project `--remote` would use. */ +export const linkedAlternativeHint = (input: { + readonly ref: string; + readonly cache: Option.Option; +}): string => { + const cached = Option.getOrUndefined(input.cache); + const name = cached?.ref === input.ref ? cached.name : undefined; + return `default; pass --remote for ${named(name, input.ref)}`; +}; + +/** The `config.target_is_branch` attribute: false for the default branch, omitted when unknown. */ +export const targetIsBranchAttribute = (target: { + readonly branch?: string | undefined; + readonly isDefault?: boolean | undefined; +}): { readonly "config.target_is_branch"?: boolean } => { + if (target.branch === undefined || target.isDefault === true) { + return { "config.target_is_branch": false }; + } + return target.isDefault === undefined ? {} : { "config.target_is_branch": true }; +}; + +/** + * A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` + * for anything but a name lookup. The default branch is the linked project itself, so it renders + * as production. + */ +export const renderBranchTarget = (input: { + readonly branch: Option.Option; + readonly ref: string; + readonly parentRef: Option.Option; + readonly parentName?: string | undefined; + readonly isDefault?: boolean; +}): RenderedTarget => { + if (input.isDefault === true) return productionTarget(input.ref, input.parentName); + const branch = Option.map(input.branch, sanitizeInlineName); + const parentRef = Option.getOrUndefined(input.parentRef); + const label = Option.match(branch, { + onNone: () => `branch ${input.ref}`, + onSome: (name) => `branch ${name} (${input.ref})`, + }); + return { + text: + parentRef === undefined ? label : `project ${named(input.parentName, parentRef)} · ${label}`, + json: { + kind: "linked", + project_ref: input.ref, + environment: "branch", + ...(parentRef === undefined ? {} : { parent_project_ref: parentRef }), + }, + }; +}; diff --git a/apps/cli/src/command-internal/target/target-identity.unit.test.ts b/apps/cli/src/command-internal/target/target-identity.unit.test.ts new file mode 100644 index 0000000000..ce351562f5 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.unit.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { + describeLinkedTarget, + linkedAlternativeHint, + renderBranchTarget, + renderDbUrlTarget, + renderLocalTarget, + targetIsBranchAttribute, +} from "./target-identity.ts"; + +const REF = "abcdefghijklmnopqrst"; +const OTHER_REF = "qrstuvwxyzabcdefghij"; + +describe("renderLocalTarget", () => { + it("renders the host and port", () => { + expect(renderLocalTarget("127.0.0.1", 54322)).toEqual({ + text: "local stack (127.0.0.1:54322)", + json: { kind: "local", host: "127.0.0.1", port: 54322 }, + }); + }); + + it("brackets an IPv6 host", () => { + expect(renderLocalTarget("::1", 54322).text).toBe("local stack ([::1]:54322)"); + }); +}); + +describe("renderDbUrlTarget", () => { + it("names the host without credentials", () => { + expect(renderDbUrlTarget("db.example.com", 5432)).toEqual({ + text: "custom Postgres (db.example.com:5432)", + json: { kind: "db-url", host: "db.example.com", port: 5432 }, + }); + }); +}); + +describe("describeLinkedTarget", () => { + it("is production when the cache names the resolved ref", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · production`, + json: { kind: "linked", project_ref: REF, project_name: "alpha", environment: "production" }, + }); + }); + + it("omits the name when the cache has none", () => { + const target = describeLinkedTarget({ ref: REF, cache: Option.some({ ref: REF }) }); + expect(target.text).toBe(`project ${REF} · production`); + expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); + }); + + it("never claims a branch for a ref the cache does not name", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "unverified" }, + }); + }); + + it("is just the ref without a cache", () => { + const target = describeLinkedTarget({ ref: REF, cache: Option.none() }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("says a ref that differs from the linked project is not the linked project", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.some({ ref: REF, name: "alpha" }), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${OTHER_REF} (not the linked project)`); + expect(target.json).toEqual({ + kind: "linked", + project_ref: OTHER_REF, + environment: "unverified", + }); + }); + + it("does not flag the linked ref itself", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("does not flag a ref when nothing is linked", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.none(), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("shows the project name the picker gave", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.none(), + name: "beta", + linkedRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project beta (${OTHER_REF}) (not the linked project)`, + json: { + kind: "linked", + project_ref: OTHER_REF, + project_name: "beta", + environment: "unverified", + }, + }); + }); + + it("strips control characters and newlines from a cached project name", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.some({ ref: REF, name: "alpha\nTarget: forged\u001b[31m" }), + }); + expect(target.text).not.toContain("\n"); + expect(target.text).not.toContain("\u001b"); + }); +}); + +describe("linkedAlternativeHint", () => { + it("names the cached project when the cache matches the linked ref", () => { + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: REF, name: "alpha" }) }), + ).toBe(`default; pass --remote for alpha (${REF})`); + }); + + it("falls back to the ref when the cache is absent or names another project", () => { + const expected = `default; pass --remote for ${REF}`; + expect(linkedAlternativeHint({ ref: REF, cache: Option.none() })).toBe(expected); + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: OTHER_REF, name: "beta" }) }), + ).toBe(expected); + }); +}); + +describe("renderBranchTarget", () => { + it("names the parent, branch and resolved ref for a name lookup", () => { + const target = renderBranchTarget({ + branch: Option.some("feature-x"), + ref: OTHER_REF, + parentRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project ${REF} · branch feature-x (${OTHER_REF})`, + json: { + kind: "linked", + project_ref: OTHER_REF, + environment: "branch", + parent_project_ref: REF, + }, + }); + }); + + it("shows only the resolved ref for a UUID lookup", () => { + const target = renderBranchTarget({ + branch: Option.none(), + ref: OTHER_REF, + parentRef: Option.none(), + }); + expect(target).toEqual({ + text: `branch ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "branch" }, + }); + }); + + it("names the parent project when its name is known", () => { + const target = renderBranchTarget({ + branch: Option.some("feature-x"), + ref: OTHER_REF, + parentRef: Option.some(REF), + parentName: "alpha", + }); + expect(target.text).toBe(`project alpha (${REF}) · branch feature-x (${OTHER_REF})`); + }); + + it("renders the default branch as the production project", () => { + const target = renderBranchTarget({ + branch: Option.some("main"), + ref: REF, + parentRef: Option.some(REF), + parentName: "alpha", + isDefault: true, + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · production`, + json: { kind: "linked", project_ref: REF, project_name: "alpha", environment: "production" }, + }); + }); + + it("renders an unnamed default branch by its ref", () => { + const target = renderBranchTarget({ + branch: Option.some("main"), + ref: REF, + parentRef: Option.some(REF), + isDefault: true, + }); + expect(target.text).toBe(`project ${REF} · production`); + expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); + }); +}); + +describe("targetIsBranchAttribute", () => { + it("is false for a target with no branch", () => { + expect(targetIsBranchAttribute({})).toEqual({ "config.target_is_branch": false }); + }); + + it("is false for the default branch, which is the production project", () => { + expect(targetIsBranchAttribute({ branch: "main", isDefault: true })).toEqual({ + "config.target_is_branch": false, + }); + }); + + it("is true for any other branch", () => { + expect(targetIsBranchAttribute({ branch: "feature-x", isDefault: false })).toEqual({ + "config.target_is_branch": true, + }); + }); + + it("is omitted for a branch UUID whose default-ness is unknown", () => { + expect(targetIsBranchAttribute({ branch: "11111111-2222-4333-8444-555555555555" })).toEqual({}); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-password.ts b/apps/cli/src/command-internal/target/target-password.ts new file mode 100644 index 0000000000..7cd076ce5c --- /dev/null +++ b/apps/cli/src/command-internal/target/target-password.ts @@ -0,0 +1,22 @@ +import { Effect, Option } from "effect"; + +import { SUGGEST_LINKED_PROJECT } from "../connect-errors.ts"; +import { TargetPasswordConflictError } from "./target.errors.ts"; + +/** Fails when a non-empty `--password` accompanies a target that is not a linked project. */ +export const rejectTargetPassword = ( + target: "local" | "db-url", + password: Option.Option, + linkedCheckout = false, +): Effect.Effect => + Option.isNone(Option.filter(password, (value) => value.length > 0)) + ? Effect.void + : Effect.fail( + new TargetPasswordConflictError({ + target, + message: + target === "db-url" + ? "--password can't be used with --remote=. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" + : `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.${linkedCheckout ? ` ${SUGGEST_LINKED_PROJECT}` : ""}`, + }), + ); diff --git a/apps/cli/src/command-internal/target/target-policy.ts b/apps/cli/src/command-internal/target/target-policy.ts new file mode 100644 index 0000000000..30365b3ee6 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.ts @@ -0,0 +1,59 @@ +import { Context, Layer, Option } from "effect"; +import { Command } from "effect/cli"; + +export type TargetGroup = "local-only" | "account" | "hosted" | "selectable" | "removed"; + +export interface TargetPolicyShape { + readonly group: TargetGroup; + readonly consent: "none" | "no" | "yes"; + readonly consentExemptReason?: string | undefined; +} + +/** + * What a leaf declares. `consentExemptReason` explains why a leaf never asks for hosted-mutation + * consent; a `selectable` leaf must give one because it can reach a hosted database. + */ +export type TargetPolicyDeclaration = + | { + readonly group: "selectable"; + readonly consent: "none"; + readonly consentExemptReason: string; + } + | { + readonly group: Exclude; + readonly consent: "none"; + readonly consentExemptReason?: string; + } + | { + readonly group: TargetGroup; + readonly consent: "no" | "yes"; + readonly consentExemptReason?: never; + }; + +/** The command's target group and hosted-mutation consent default, provided to its handler. */ +export class TargetPolicy extends Context.Service()( + "supabase/TargetPolicy", +) {} + +/** Context key under which a command's policy is stored in `Command.annotations`. */ +export class TargetPolicyAnnotation extends Context.Service< + TargetPolicyAnnotation, + TargetPolicyShape +>()("supabase/TargetPolicyAnnotation") {} + +/** + * Declares a leaf's target group and consent default; apply it after the leaf's handler and its + * other `Command.provide` layers so every one of them can read `TargetPolicy`. + */ +export const withTargetPolicy = + (policy: TargetPolicyDeclaration) => + ( + self: Command.Command, + ) => + self.pipe( + Command.annotate(TargetPolicyAnnotation, policy), + Command.provide(Layer.succeed(TargetPolicy, policy)), + ); + +export const readTargetPolicy = (command: Command.Command.Any): Option.Option => + Context.getOption(command.annotations, TargetPolicyAnnotation); diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts new file mode 100644 index 0000000000..dbe7ef8626 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -0,0 +1,265 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { TreeNode } from "../../../tests/helpers/command-flags.ts"; +import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readPermissions } from "../command-permissions/command-permissions.annotation.ts"; +import { TargetPolicy, readTargetPolicy, withTargetPolicy } from "./target-policy.ts"; +import type { TargetGroup, TargetPolicyShape } from "./target-policy.ts"; + +const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ + { name: "default backend", variant: undefined }, + { + name: "stack backend", + variant: { + projectState: Exit.succeed("stack"), + stackBackend: "stack", + computeEnabled: true, + } satisfies Required>, + }, +]; + +/** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ +const CONSENT_FREE_OPERATIONS: ReadonlyMap = new Map([ + [ + "v1-create-login-role", + "mints a short-lived login role for the connection; revisit when connection setup asks for consent", + ], + ["v1-delete-network-bans", "removes the caller's own ban only when the token permits it"], + ["v1-list-all-network-bans", "a POST that only reads the ban list"], + ["v1-verify-dns-config", "re-runs verification and changes no configuration"], + ["v1-check-vanity-subdomain-availability", "a POST that only reads availability"], +]); + +/** Non-GET operations a single leaf may call without consent, keyed by its command path. */ +const LEAF_CONSENT_FREE_OPERATIONS: ReadonlyMap> = new Map([ + [ + "notebooks pull", + new Map([["v2-create-notebook", "creates remote notebooks only after an interactive choice"]]), + ], +]); + +const isConsentFree = (path: string, operationId: string) => + CONSENT_FREE_OPERATIONS.has(operationId) || + LEAF_CONSENT_FREE_OPERATIONS.get(path)?.has(operationId) === true; + +const MUTATING_VERBS: ReadonlySet = new Set([ + "create", + "delete", + "remove", + "update", + "set", + "unset", + "deploy", + "push", + "restore", + "activate", + "disable", + "pause", + "unpause", + "add", + "up", + "down", + "squash", + "repair", + "reset", + "query", + "cp", + "mv", + "rm", + "buckets", +]); + +const TARGET_SELECTOR_FLAGS = ["local", "linked", "db-url"] as const; + +interface Leaf { + readonly path: string; + readonly node: TreeNode; + readonly policy: TargetPolicyShape; +} + +const leavesByVariant = VARIANTS.map(({ name, variant }) => { + const tree = commandTreeFor(variant); + const leaves = [...tree].filter(([, node]) => node.isLeaf); + return { + name, + withPolicy: leaves.flatMap(([path, node]): ReadonlyArray => { + const policy = readTargetPolicy(node.command); + return Option.isSome(policy) ? [{ path, node, policy: policy.value }] : []; + }), + missingPolicy: leaves + .filter(([, node]) => Option.isNone(readTargetPolicy(node.command))) + .map(([path]) => path), + }; +}); + +const lastWord = (path: string) => path.split(" ").at(-1) ?? path; + +describe.each(leavesByVariant)( + "target policy tree guards ($name)", + ({ withPolicy, missingPolicy }) => { + it("declares a target policy on every leaf", () => { + expect(missingPolicy).toEqual([]); + }); + + it("gives local-only, account and hosted leaves no local, linked or db-url flag", () => { + const offenders = withPolicy + .filter(({ policy }) => ["local-only", "account", "hosted"].includes(policy.group)) + .flatMap(({ path, node }) => + TARGET_SELECTOR_FLAGS.filter((flag) => node.flags.has(flag)).map( + (flag) => `${path} --${flag}`, + ), + ); + expect(offenders).toEqual([]); + }); + + it("gives selectable leaves remote, local, linked and project-ref flags", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "selectable") + .flatMap(({ path, node }) => + (["remote", "local", "linked", "project-ref"] as const) + .filter((flag) => !node.flags.has(flag)) + .map((flag) => `${path} lacks --${flag}`), + ); + expect(offenders).toEqual([]); + }); + + it("requires consent for every declared non-GET operation outside the consent-free map", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.consent === "none") + .flatMap(({ path, node }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped") return []; + return permissions.operations + .filter( + ({ operationId }) => + OPERATIONS.get(operationId)?.method !== "GET" && !isConsentFree(path, operationId), + ) + .map(({ operationId }) => `${path}: ${operationId}`); + }); + expect([...new Set(offenders)]).toEqual([]); + }); + + it("requires an exemption reason on a selectable leaf that never asks for consent", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "selectable") + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + + it("gives every leaf-scoped consent-free operation a leaf that declares its reason", () => { + const reasonByPath = new Map(withPolicy.map(({ path, policy }) => [path, policy])); + const offenders = [...LEAF_CONSENT_FREE_OPERATIONS.keys()].filter((path) => { + const policy = reasonByPath.get(path); + return ( + policy === undefined || + policy.consent !== "none" || + (policy.consentExemptReason ?? "").trim() === "" + ); + }); + expect(offenders).toEqual([]); + }); + + it("requires consent or an exemption reason on a leaf named for a mutating verb", () => { + const offenders = withPolicy + .filter(({ path }) => MUTATING_VERBS.has(lastWord(path))) + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + }, +); + +describe("target policy allowlists", () => { + it("name only operations in the API spec", () => { + const unknown = [ + ...CONSENT_FREE_OPERATIONS.keys(), + ...[...LEAF_CONSENT_FREE_OPERATIONS.values()].flatMap((operations) => [...operations.keys()]), + ].filter((operationId) => !OPERATIONS.has(operationId)); + expect(unknown).toEqual([]); + }); +}); + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const runtimeLayer = Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter)); + +class GroupProbe extends Context.Service()("test/GroupProbe") {} + +describe("withTargetPolicy", () => { + it("annotates the command with the declared policy", () => { + const leaf = Command.make("leaf").pipe(withTargetPolicy({ group: "hosted", consent: "no" })); + expect(readTargetPolicy(leaf)).toEqual(Option.some({ group: "hosted", consent: "no" })); + }); + + it("requires a reason from a selectable declaration that never asks for consent", () => { + // @ts-expect-error a leaf that can reach a hosted database must say why it never asks + withTargetPolicy({ group: "selectable", consent: "none" }); + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }); + withTargetPolicy({ group: "hosted", consent: "none" }); + }); + + it("reads no policy from an unannotated command", () => { + expect(readTargetPolicy(Command.make("leaf"))).toEqual(Option.none()); + }); + + it.effect("provides the policy to the handler", () => + Effect.gen(function* () { + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* TargetPolicy); + }), + ), + withTargetPolicy({ group: "selectable", consent: "yes" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual([{ group: "selectable", consent: "yes" }]); + }), + ); + + it.effect("provides the policy to layers applied before it", () => + Effect.gen(function* () { + const probeLayer = Layer.effect( + GroupProbe, + Effect.gen(function* () { + return (yield* TargetPolicy).group; + }), + ); + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* GroupProbe); + }), + ), + Command.provide(probeLayer), + withTargetPolicy({ group: "account", consent: "none" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual(["account"]); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts new file mode 100644 index 0000000000..7e62c07e6b --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -0,0 +1,218 @@ +import { Option, Result } from "effect"; + +import { + parseNamedRemote, + type NamedRemoteRejection, + type NamedRemoteValue, + type RemoteValue, +} from "./remote-value.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; +import { + TargetEmptyValueError, + TargetInvalidValueError, + TargetProjectRefConflictError, + TargetSelectorConflictError, + type TargetSelectorError, +} from "./target.errors.ts"; + +/** What the flags select: the default, the local stack, or a remote project, branch or database. */ +type TargetSelection = { readonly _tag: "Default" } | { readonly _tag: "Local" } | RemoteValue; + +type RemoteAliasFlag = "--linked" | "--project-ref" | "--project-id" | "--db-url"; + +/** A deprecated selector flag the run used, with the `--remote` form it maps to. */ +export type TargetDeprecation = + | { + readonly kind: "remote-alias"; + readonly flags: ReadonlyArray; + readonly remote: RemoteValue; + } + | { readonly kind: "local" }; + +export interface ParsedTargetSelector { + readonly selection: TargetSelection; + readonly deprecations: ReadonlyArray; +} + +export interface TargetSelectorOptions { + /** What `--remote` takes here, for suggestions; defaults to `` without `dbUrl`. */ + readonly valueHint?: string; + /** `--remote` is given more than once with values that differ. */ + readonly remoteConflict?: boolean; +} + +/** Lists flag names as `a`, `a and b` or `a, b and c`. */ +export const joinFlags = (flags: ReadonlyArray): string => + flags.length <= 2 + ? flags.join(" and ") + : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; + +const emptyValueError = (flag: "--db-url" | "--project-ref" | "--project-id") => + new TargetEmptyValueError({ + flag, + message: `${flag} cannot be empty.`, + suggestion: + flag === "--db-url" + ? "Pass a connection string as --remote=." + : "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + }); + +const PROJECT_REF_CONFLICT_SUGGESTION: Readonly< + Record<"--remote" | "--local" | "--db-url", string> +> = { + "--remote": "Use --remote= alone.", + "--local": "Use --remote= for the project, or leave both out for the local database.", + "--db-url": "Use --remote= for the project or --remote= for the database, not both.", +}; + +const trimmed = (value: string): string => value.trim(); + +/** Why an alias value is unusable: a foreign URL, a keyword DSN, or a URL with no database. */ +const aliasRejection = ( + parsed: Result.Result | undefined, + hasDatabase: boolean, +): NamedRemoteRejection | undefined => { + if (parsed === undefined) return undefined; + if (Result.isFailure(parsed)) return parsed.failure; + return parsed.success._tag === "DbUrl" && !hasDatabase + ? { _tag: "ForeignUrl", ref: undefined } + : undefined; +}; + +/** + * A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. + * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve like the `--remote` form + * they map to and are reported as deprecations; `--local` selects the default. + */ +export const parseTargetSelector = ( + input: TargetFlagValues, + options: TargetSelectorOptions = {}, +): Result.Result => { + const remote = Option.getOrUndefined(input.remote); + const dbUrl = Option.getOrUndefined(Option.map(input.dbUrl ?? Option.none(), trimmed)); + const projectRef = Option.getOrUndefined(Option.map(input.projectRef, trimmed)); + const projectId = Option.getOrUndefined(Option.map(input.projectId ?? Option.none(), trimmed)); + const valueHint = + options.valueHint ?? (input.dbUrl === undefined ? "" : ""); + + if (dbUrl === "") return Result.fail(emptyValueError("--db-url")); + if (projectRef === "") return Result.fail(emptyValueError("--project-ref")); + if (projectId === "") return Result.fail(emptyValueError("--project-id")); + + if (options.remoteConflict === true) { + return Result.fail( + new TargetSelectorConflictError({ + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: `Pass --remote once, as --remote or --remote=${valueHint}.`, + }), + ); + } + + const selected = [ + ...(remote === undefined ? [] : ["--remote"]), + ...(input.linked ? ["--linked"] : []), + ...(dbUrl === undefined ? [] : ["--db-url"]), + ...(input.local ? ["--local"] : []), + ]; + const available = + input.dbUrl === undefined + ? ["--remote", "--linked", "--local"] + : ["--remote", "--linked", "--db-url", "--local"]; + if (selected.length > 1) { + return Result.fail( + new TargetSelectorConflictError({ + flags: selected, + message: `Only one of ${joinFlags(available)} can be set; got ${joinFlags(selected)}.`, + suggestion: `Use --remote or --remote=${valueHint} on its own.`, + }), + ); + } + + if (projectRef !== undefined && projectId !== undefined) { + return Result.fail( + new TargetSelectorConflictError({ + flags: ["--project-id", "--project-ref"], + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + suggestion: "Use --remote= instead.", + }), + ); + } + const aliasFlag = projectRef === undefined ? "--project-id" : "--project-ref"; + const aliasValue = projectRef ?? projectId; + if (aliasValue !== undefined) { + const conflictsWith = input.local + ? "--local" + : dbUrl !== undefined + ? "--db-url" + : remote !== undefined && remote._tag !== "Linked" + ? "--remote" + : undefined; + if (conflictsWith !== undefined) { + return Result.fail( + new TargetProjectRefConflictError({ + conflictsWith, + message: + conflictsWith === "--remote" + ? `${aliasFlag} cannot be combined with --remote=.` + : `${aliasFlag} selects a remote project and cannot be combined with ${conflictsWith}.`, + suggestion: PROJECT_REF_CONFLICT_SUGGESTION[conflictsWith], + }), + ); + } + } + + const aliasRemote = aliasValue === undefined ? undefined : parseNamedRemote(aliasValue); + const rejectedAlias = aliasRejection(aliasRemote, input.dbUrl !== undefined); + if (rejectedAlias !== undefined) { + const ref = rejectedAlias._tag === "ForeignUrl" ? rejectedAlias.ref : undefined; + return Result.fail( + new TargetInvalidValueError({ + flag: aliasFlag, + message: `${aliasFlag} takes a project ref or a branch name, not a ${rejectedAlias._tag === "ForeignUrl" ? "URL" : "connection string"}.`, + suggestion: + ref === undefined + ? "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst" + : `Use --remote=${ref}.`, + ref, + }), + ); + } + + const outcome = ((): { + readonly selection: TargetSelection; + readonly deprecations: ReadonlyArray; + } => { + if (input.local) return { selection: { _tag: "Local" }, deprecations: [{ kind: "local" }] }; + if (dbUrl !== undefined) { + const value: RemoteValue = { _tag: "DbUrl", url: dbUrl }; + return { + selection: value, + deprecations: [{ kind: "remote-alias", flags: ["--db-url"], remote: value }], + }; + } + if (aliasRemote !== undefined && Result.isSuccess(aliasRemote)) { + return { + selection: aliasRemote.success, + deprecations: [ + { + kind: "remote-alias", + flags: input.linked ? ["--linked", aliasFlag] : [aliasFlag], + remote: aliasRemote.success, + }, + ], + }; + } + if (input.linked) { + const value: RemoteValue = { _tag: "Linked" }; + return { + selection: value, + deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: value }], + }; + } + if (remote !== undefined) return { selection: remote, deprecations: [] }; + return { selection: { _tag: "Default" }, deprecations: [] }; + })(); + + return Result.succeed(outcome); +}; diff --git a/apps/cli/src/command-internal/target/target-selector.unit.test.ts b/apps/cli/src/command-internal/target/target-selector.unit.test.ts new file mode 100644 index 0000000000..d0a8a50c7e --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.unit.test.ts @@ -0,0 +1,406 @@ +import { Option, Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import type { RemoteValue } from "./remote-value.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; +import { parseTargetSelector, type TargetSelectorOptions } from "./target-selector.ts"; + +const REF = "abcdefghijklmnopqrst"; +const URL = "postgresql://u:p@db.example.com:5432/postgres"; + +const none: TargetFlagValues = { + remote: Option.none(), + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const remote = (value: RemoteValue) => ({ remote: Option.some(value) }); + +const parse = (overrides: Partial, options?: TargetSelectorOptions) => + parseTargetSelector({ ...none, ...overrides }, options); + +const success = (overrides: Partial) => { + const result = parse(overrides); + if (Result.isFailure(result)) + throw new Error(`expected the selector to be accepted: ${result.failure.message}`); + return result.success; +}; + +const failure = (overrides: Partial, options?: TargetSelectorOptions) => { + const result = parse(overrides, options); + if (Result.isSuccess(result)) throw new Error("expected the selector to be rejected"); + return result.failure; +}; + +describe("parseTargetSelector", () => { + it("reports no explicit target and no deprecation when nothing is set", () => { + expect(success({})).toEqual({ selection: { _tag: "Default" }, deprecations: [] }); + }); + + describe("--remote", () => { + it.each([ + ["a bare --remote", { _tag: "Linked" }, { _tag: "Linked" }], + ["a project ref", { _tag: "Ref", ref: REF }, { _tag: "Ref", ref: REF }], + ["a database URL", { _tag: "DbUrl", url: URL }, { _tag: "DbUrl", url: URL }], + [ + "a branch name", + { _tag: "Branch", name: "feature-x" }, + { _tag: "Branch", name: "feature-x" }, + ], + ] as const)( + "selects the target named by %s without a deprecation", + (_name, value, selection) => { + expect(success(remote(value))).toEqual({ selection, deprecations: [] }); + }, + ); + }); + + describe("deprecated aliases", () => { + it("treats --local as the default and reports it", () => { + expect(success({ local: true })).toEqual({ + selection: { _tag: "Local" }, + deprecations: [{ kind: "local" }], + }); + }); + + it("maps --linked to a bare --remote", () => { + expect(success({ linked: true })).toEqual({ + selection: { _tag: "Linked" }, + deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }], + }); + }); + + it("maps --db-url to --remote=", () => { + expect(success({ dbUrl: Option.some(URL) })).toEqual({ + selection: { _tag: "DbUrl", url: URL }, + deprecations: [ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url: URL } }, + ], + }); + }); + + it.each(["--project-ref", "--project-id"] as const)( + "maps %s with a ref to --remote=", + (flag) => { + const overrides = + flag === "--project-ref" + ? { projectRef: Option.some(REF) } + : { projectId: Option.some(REF) }; + expect(success(overrides)).toEqual({ + selection: { _tag: "Ref", ref: REF }, + deprecations: [ + { kind: "remote-alias", flags: [flag], remote: { _tag: "Ref", ref: REF } }, + ], + }); + }, + ); + + it("maps --project-ref with a branch name to --remote=", () => { + expect(success({ projectRef: Option.some("feature-x") })).toEqual({ + selection: { _tag: "Branch", name: "feature-x" }, + deprecations: [ + { + kind: "remote-alias", + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "feature-x" }, + }, + ], + }); + }); + + it("folds --linked and --project-ref into one deprecation", () => { + expect(success({ linked: true, projectRef: Option.some(REF) })).toEqual({ + selection: { _tag: "Ref", ref: REF }, + deprecations: [ + { + kind: "remote-alias", + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + }, + ], + }); + }); + + it("lets a bare --remote agree with --project-ref", () => { + expect( + success({ ...remote({ _tag: "Linked" }), projectRef: Option.some(REF) }), + ).toMatchObject({ + selection: { _tag: "Ref", ref: REF }, + }); + }); + + it("treats false switches as absent", () => { + expect(success({ local: false, linked: false })).toEqual({ + selection: { _tag: "Default" }, + deprecations: [], + }); + expect(success({ linked: false, dbUrl: Option.some(URL) })).toMatchObject({ + selection: { _tag: "DbUrl", url: URL }, + }); + }); + }); + + describe("alias values", () => { + it.each([ + { flag: "--project-ref", overrides: { projectRef: Option.some("https://example.com") } }, + { flag: "--project-id", overrides: { projectId: Option.some("https://example.com") } }, + ] as const)("rejects a URL given to $flag", ({ flag, overrides }) => { + expect(failure(overrides)).toMatchObject({ + _tag: "TargetInvalidValueError", + flag, + message: `${flag} takes a project ref or a branch name, not a URL.`, + suggestion: + "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", + }); + }); + + it("rejects a keyword connection string given to --project-ref without echoing it", () => { + const error = failure({ projectRef: Option.some("host=db.example.com password=hunter2") }); + expect(error).toMatchObject({ + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a connection string.", + }); + expect(JSON.stringify(error)).not.toContain("hunter2"); + }); + + it("suggests the ref a project URL already names and keeps it on the error", () => { + expect(failure({ projectRef: Option.some(`https://${REF}.supabase.co`) })).toMatchObject({ + _tag: "TargetInvalidValueError", + suggestion: `Use --remote=${REF}.`, + ref: REF, + }); + }); + + it("trims an alias value before it is classified", () => { + expect(success({ projectRef: Option.some(` ${REF} `) }).selection).toEqual({ + _tag: "Ref", + ref: REF, + }); + }); + + it.each(["true", "false"])( + "keeps looking up a branch named %s through --project-ref", + (name) => { + expect(success({ projectRef: Option.some(name) }).selection).toEqual({ + _tag: "Branch", + name, + }); + }, + ); + + it("keeps a keyword connection string given to --db-url working", () => { + const dsn = "host=db.example.com user=postgres password=pw"; + expect(success({ dbUrl: Option.some(dsn) }).selection).toEqual({ _tag: "DbUrl", url: dsn }); + }); + + it("treats a postgres URL in any case as a database", () => { + expect(success({ projectRef: Option.some("POSTGRESQL://h/db") }).selection).toEqual({ + _tag: "DbUrl", + url: "postgresql://h/db", + }); + }); + }); + + describe("conflicts", () => { + it.each([ + ["--linked", { linked: true }, ["--remote", "--linked"]], + ["--db-url", { dbUrl: Option.some(URL) }, ["--remote", "--db-url"]], + ["--local", { local: true }, ["--remote", "--local"]], + ] as const)("rejects --remote with %s and names both flags", (_flag, overrides, flags) => { + expect(failure({ ...remote({ _tag: "Linked" }), ...overrides })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags, + suggestion: "Use --remote or --remote= on its own.", + }); + }); + + it("lists every selector that was set, in a fixed order", () => { + expect( + failure({ + ...remote({ _tag: "Linked" }), + local: true, + linked: true, + dbUrl: Option.some(URL), + }), + ).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote", "--linked", "--db-url", "--local"], + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --remote, --linked, --db-url and --local.", + }); + }); + + it.each([ + { name: "with a database input", overrides: {}, hint: "" }, + { name: "without one", overrides: { dbUrl: undefined }, hint: "" }, + ])("rejects a --remote given twice with different values, $name", ({ overrides, hint }) => { + expect( + failure({ ...remote({ _tag: "Linked" }), ...overrides }, { remoteConflict: true }), + ).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: `Pass --remote once, as --remote or --remote=${hint}.`, + }); + }); + + it("rejects --local with --linked", () => { + expect(failure({ local: true, linked: true })).toMatchObject({ + flags: ["--linked", "--local"], + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", + }); + }); + + it.each([ + [ + "--local", + { local: true }, + "--project-ref selects a remote project and cannot be combined with --local.", + ], + [ + "--db-url", + { dbUrl: Option.some(URL) }, + "--project-ref selects a remote project and cannot be combined with --db-url.", + ], + [ + "--remote=", + remote({ _tag: "Ref", ref: REF }), + "--project-ref cannot be combined with --remote=.", + ], + ] as const)("rejects --project-ref with %s", (_flag, overrides, message) => { + expect(failure({ ...overrides, projectRef: Option.some(REF) })).toMatchObject({ + _tag: "TargetProjectRefConflictError", + message, + }); + }); + + it.each([ + ["--local", { local: true }, "Use --remote= for the project, or leave both out"], + [ + "--db-url", + { dbUrl: Option.some(URL) }, + "Use --remote= for the project or --remote= for the database, not both.", + ], + ["--remote=", remote({ _tag: "Ref", ref: REF }), "Use --remote= alone."], + ] as const)( + "suggests the --remote form when --project-ref meets %s", + (_flag, overrides, hint) => { + expect(failure({ ...overrides, projectRef: Option.some(REF) })).toMatchObject({ + suggestion: expect.stringContaining(hint), + }); + }, + ); + + it("lists only the selectors a command without a database connection has", () => { + const result = parseTargetSelector({ + remote: Option.some({ _tag: "Linked" }), + local: true, + linked: false, + projectRef: Option.none(), + }); + expect(result).toMatchObject({ + failure: { + _tag: "TargetSelectorConflictError", + message: + "Only one of --remote, --linked and --local can be set; got --remote and --local.", + suggestion: "Use --remote or --remote= on its own.", + }, + }); + }); + + it("suggests the parent-scoped hint when the command passes one", () => { + expect( + failure({ ...remote({ _tag: "Linked" }), linked: true }, { valueHint: "" }), + ).toMatchObject({ suggestion: "Use --remote or --remote= on its own." }); + }); + + it("rejects --project-id with --project-ref and suggests --remote=", () => { + expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--project-id", "--project-ref"], + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + suggestion: "Use --remote= instead.", + }); + }); + + it("reports the selector conflict before the --project-ref conflict", () => { + expect(failure({ local: true, linked: true, projectRef: Option.some(REF) })).toMatchObject({ + _tag: "TargetSelectorConflictError", + }); + }); + }); + + describe("empty values", () => { + it.each([ + ["--db-url", { dbUrl: Option.some("") }, "Pass a connection string as --remote=."], + [ + "--project-ref", + { projectRef: Option.some("") }, + "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + ], + [ + "--project-id", + { projectId: Option.some("") }, + "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + ], + ] as const)("rejects an empty %s", (flag, overrides, suggestion) => { + expect(failure(overrides)).toMatchObject({ + _tag: "TargetEmptyValueError", + flag, + message: `${flag} cannot be empty.`, + suggestion, + }); + }); + + it("reports an empty value before any conflict", () => { + expect(failure({ local: true, dbUrl: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--db-url", + }); + }); + }); + + describe("a command with no database connection", () => { + const hosted = (overrides: Partial, options?: TargetSelectorOptions) => + parseTargetSelector( + { + remote: Option.none(), + local: false, + linked: false, + projectRef: Option.none(), + ...overrides, + }, + options, + ); + + it("accepts a project ref and a branch", () => { + expect(hosted(remote({ _tag: "Ref", ref: REF }))).toEqual( + Result.succeed({ + selection: { _tag: "Ref", ref: REF }, + deprecations: [], + }), + ); + expect(hosted(remote({ _tag: "Branch", name: "feature-x" }))).toMatchObject({ + success: { selection: { _tag: "Branch", name: "feature-x" } }, + }); + }); + + it("rejects a database URL given to --project-ref without echoing it", () => { + const result = hosted({ projectRef: Option.some(URL) }); + expect(result).toMatchObject({ + failure: { + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", + suggestion: + "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", + }, + }); + expect(JSON.stringify(result)).not.toContain("db.example.com"); + }); + }); +}); diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts new file mode 100644 index 0000000000..e11d882c93 --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -0,0 +1,102 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + +/** Several of `--remote`, `--linked`, `--db-url`, `--local`, or two different `--remote` values. */ +export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ + readonly flags: ReadonlyArray; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--project-ref` cannot be combined with `--remote=`, `--local` or `--db-url`. */ +export class TargetProjectRefConflictError extends Data.TaggedError( + "TargetProjectRefConflictError", +)<{ + readonly conflictsWith: "--remote" | "--local" | "--db-url"; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--db-url`, `--project-ref` or `--project-id` was passed an empty value. */ +export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueError")<{ + readonly flag: "--db-url" | "--project-ref" | "--project-id"; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--project-ref` or `--project-id` was given a URL, not a project ref or branch name. */ +export class TargetInvalidValueError extends Data.TaggedError("TargetInvalidValueError")<{ + readonly flag: "--project-ref" | "--project-id"; + readonly message: string; + readonly suggestion: string; + /** The project ref the rejected URL names, when it names one. */ + readonly ref?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** A command that acts on a parent project was given a branch name. */ +export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjectError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * A bare `--remote` is followed by a word that names a branch, nearly names one, or could not be + * checked because the branch listing failed; `status` is that failure's HTTP status, when it had + * one, and `authFailure` says the access token was missing or invalid (`token` when only the + * environment variable can fix it). + */ +export class TargetRemoteAmbiguousError extends Data.TaggedError("TargetRemoteAmbiguousError")<{ + readonly message: string; + readonly suggestion?: string; + readonly status?: number; + readonly authFailure?: "login" | "token"; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.authFailure === "token") return actionability.authToken; + if (this.status === 401 || this.status === 403) return statusCodeActionability(this.status); + if (this.authFailure === "login") return actionability.authLogin; + return actionability.provideFlags; + } +} + +/** `--password` was given while the resolved target is not a linked project. */ +export class TargetPasswordConflictError extends Data.TaggedError("TargetPasswordConflictError")<{ + readonly target: "local" | "db-url"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export type TargetSelectorError = + | TargetSelectorConflictError + | TargetProjectRefConflictError + | TargetEmptyValueError + | TargetInvalidValueError; diff --git a/apps/cli/src/command-internal/target/target.errors.unit.test.ts b/apps/cli/src/command-internal/target/target.errors.unit.test.ts new file mode 100644 index 0000000000..c84b7b3549 --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.unit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + TargetEmptyValueError, + TargetInvalidValueError, + TargetNeedsProjectError, + TargetPasswordConflictError, + TargetProjectRefConflictError, + TargetRemoteAmbiguousError, + TargetSelectorConflictError, +} from "./target.errors.ts"; + +describe("target errors", () => { + it.each([ + new TargetSelectorConflictError({ + flags: ["--local", "--linked"], + message: "m", + suggestion: "s", + }), + new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m", suggestion: "s" }), + new TargetEmptyValueError({ flag: "--db-url", message: "m", suggestion: "s" }), + new TargetInvalidValueError({ flag: "--project-ref", message: "m", suggestion: "s" }), + new TargetNeedsProjectError({ message: "m" }), + new TargetRemoteAmbiguousError({ message: "m" }), + new TargetPasswordConflictError({ target: "local", message: "m" }), + ])("classifies $_tag as invalid input the user can fix", (error) => { + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + }); + }); + + it.each([401, 403])( + "classifies a failed branch listing with HTTP %s as an auth failure", + (status) => { + expect( + classifyCliErrorActionability(new TargetRemoteAmbiguousError({ message: "m", status })), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: expect.not.stringMatching(/^invalid_input$/), + }); + }, + ); +}); diff --git a/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts new file mode 100644 index 0000000000..67a37e2a7a --- /dev/null +++ b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect"; + +import { + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; + +/** Adds `suggestion` to a branch lookup that found no such branch, or had no usable link to search. */ +export const withUnresolvedBranchSuggestion = + (suggestion: string) => + ( + self: Effect.Effect, + ): Effect.Effect< + A, + E | LinkBranchNotFoundError | LinkBranchNotLinkedError | LinkParentRefInvalidError, + R + > => + Effect.mapError(self, (error) => { + if (error instanceof LinkBranchNotFoundError) { + return new LinkBranchNotFoundError({ message: error.message, suggestion }); + } + if (error instanceof LinkBranchNotLinkedError) { + return new LinkBranchNotLinkedError({ message: error.message, suggestion }); + } + if (error instanceof LinkParentRefInvalidError) { + return new LinkParentRefInvalidError({ message: error.message, suggestion }); + } + return error; + }); diff --git a/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts new file mode 100644 index 0000000000..89f6e01f3b --- /dev/null +++ b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts @@ -0,0 +1,37 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { + LinkBranchListNetworkError, + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; +import { withUnresolvedBranchSuggestion } from "./unresolved-branch-suggestion.ts"; + +const suggestion = "Try --from=local."; + +describe("withUnresolvedBranchSuggestion", () => { + it.effect.each([ + new LinkBranchNotFoundError({ message: "not found" }), + new LinkBranchNotLinkedError({ message: "not linked" }), + new LinkParentRefInvalidError({ message: "bad link" }), + ])("adds the suggestion to $_tag and keeps its message", (error) => + Effect.gen(function* () { + const failure = yield* Effect.flip( + Effect.fail(error).pipe(withUnresolvedBranchSuggestion(suggestion)), + ); + expect(failure).toMatchObject({ _tag: error._tag, message: error.message, suggestion }); + }), + ); + + it.effect("leaves other failures untouched", () => + Effect.gen(function* () { + const error = new LinkBranchListNetworkError({ message: "offline" }); + const failure = yield* Effect.flip( + Effect.fail(error).pipe(withUnresolvedBranchSuggestion(suggestion)), + ); + expect(failure).toBe(error); + }), + ); +}); diff --git a/apps/cli/src/command-internal/tenant-versions.ts b/apps/cli/src/command-internal/tenant-versions.ts index a7a5f73863..03eb1afe5b 100644 --- a/apps/cli/src/command-internal/tenant-versions.ts +++ b/apps/cli/src/command-internal/tenant-versions.ts @@ -199,10 +199,15 @@ interface TenantObservations { /** * Probes `rest`, `auth` and `storage` on the project's tenant host, concurrently. A service that * answers 404 or an empty version is `Unreported`; a transport error or any other status is `Failed`. + * Without a key nothing is probed and every service is `Failed`. */ export const probeTenantVersions = Effect.fn("TenantVersions.probe")(function* ( opts: TenantVersionOptions, ) { + if (opts.serviceKey.length === 0) { + const skipped: HostedObservation = { _tag: "Failed", cause: "no API key to probe with" }; + return { auth: skipped, rest: skipped, storage: skipped } satisfies TenantObservations; + } const [rest, auth, storage] = yield* Effect.all( [ probeService(opts, "postgrest", "/rest/v1/", (response) => response.json, postgrestRaw), diff --git a/apps/cli/src/command-internal/tenant-versions.unit.test.ts b/apps/cli/src/command-internal/tenant-versions.unit.test.ts index da46e26ff8..6412841f52 100644 --- a/apps/cli/src/command-internal/tenant-versions.unit.test.ts +++ b/apps/cli/src/command-internal/tenant-versions.unit.test.ts @@ -12,7 +12,7 @@ describe("parsePostgrestVersion", () => { expect(parsePostgrestVersion({ info: { version: "12.2.0" } })).toEqual(Option.some("v12.2.0")); }); - it("uses only the first whitespace-delimited field (Go strings.Fields)", () => { + it("uses only the first whitespace-delimited field", () => { expect(parsePostgrestVersion({ info: { version: "12.2.0 (abc123)" } })).toEqual( Option.some("v12.2.0"), ); diff --git a/apps/cli/src/command-internal/test-db.command-handler.ts b/apps/cli/src/command-internal/test-db.command-handler.ts index 9a7dca3182..a9cc071ee4 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -1,5 +1,5 @@ -import { Effect, Option } from "effect"; -import { Argument, Flag } from "effect/cli"; +import { Effect } from "effect"; +import { Argument } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../shared/output/json-error-handling.ts"; @@ -7,11 +7,12 @@ import { Output } from "../shared/output/output.service.ts"; import { ProcessControl } from "../shared/runtime/process-control.service.ts"; import { withCommandTelemetry } from "../telemetry/command-telemetry.ts"; import type { TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; +import { targetFlags, type TargetFlagValuesWithDbUrl } from "./target/target-flags.ts"; import { testDb } from "./test-db.handler.ts"; /** Shared verbatim by both `test db` and its `db test` alias. */ -export const TEST_DB_DESCRIPTION = "Tests local database with pgTAP."; -export const TEST_DB_SHORT = "Tests local database with pgTAP"; +export const TEST_DB_DESCRIPTION = "Tests a database with pgTAP."; +export const TEST_DB_SHORT = "Tests a database with pgTAP"; /** * `test db`'s entire output is the streamed pg_prove TAP on stdout. A run @@ -29,70 +30,30 @@ const onRunFailure = (error: TestDbRunError | TestDbNoTestsError) => yield* processControl.setExitCode(1); }); -/** - * Flag config shared verbatim by `supabase test db` and its hidden alias - * `supabase db test`. - * - * Lives in `command-internal/` because it (with `runTestDbCommand` and - * `testDbRuntimeLayer`) is shared across the `db` and `test` command - * families — see "Hoist Before You Duplicate" in `apps/cli/CLAUDE.md`. - */ +/** Flag config shared by `supabase test db` and its hidden `supabase db test` alias. */ export const testDbConfig = { paths: Argument.String("path").pipe( Argument.withDescription("Paths to test files or directories."), Argument.variadic(), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Tests the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Runs pgTAP tests on the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Runs pgTAP tests on the local database."), - Flag.withDefault(false), - ), - // TS-only override of the linked project ref — see push.command.ts (db push). - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ subject: "database", dbUrl: true }), } as const; -export interface TestDbFlags { +export interface TestDbFlags extends TargetFlagValuesWithDbUrl { readonly paths: ReadonlyArray; - readonly dbUrl: Option.Option; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option; } -/** - * Assembled `test db` / `db test` handler: telemetry, run-failure routing - * (`onRunFailure`), and JSON-error-handling wiring shared by both entry - * points. - */ +/** The `test db` / `db test` handler: telemetry, run-failure routing and JSON error handling. */ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer) { return testDb({ paths: flags.paths, + remote: flags.remote, dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, projectRef: flags.projectRef, }).pipe( - withCommandTelemetry({ - flags: { - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, - }, - // No safe-flag whitelist entry for --project-ref here, so it stays redacted. - }), + withCommandTelemetry(), // Run failures must not corrupt the TAP stream in machine modes; see `onRunFailure`. Effect.catchTag(["TestDbRunError", "TestDbNoTestsError"], onRunFailure), withJsonErrorHandling, diff --git a/apps/cli/src/command-internal/test-db.errors.ts b/apps/cli/src/command-internal/test-db.errors.ts index 62b81c4820..557f2b07d7 100644 --- a/apps/cli/src/command-internal/test-db.errors.ts +++ b/apps/cli/src/command-internal/test-db.errors.ts @@ -41,14 +41,3 @@ export class TestDbNoTestsError extends Data.TaggedError("TestDbNoTestsError")<{ return actionability.invalidInput; } } - -/** More than one of `--db-url` / `--linked` / `--local` was set. */ -export class TestDbMutuallyExclusiveFlagsError extends Data.TaggedError( - "TestDbMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 445cc5cbcf..05f8b0ca6d 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -7,30 +7,23 @@ import { type ArtifactPin, } from "@supabase/stack/internal/artifacts"; -import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { TelemetryState } from "../telemetry/telemetry-state.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; -import { - envOverrideLookup, - loadProjectEnv, - readDbToml, - warnDeprecatedOrioleDBVersionEnv, -} from "./db-config.toml-read.ts"; +import { readDbToml } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { resolveDbTargetFlags } from "./db-target-flags.ts"; +import { + requireTargetMutationConsent, + hostedMutationPreapproval, +} from "./hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "./target/resolve-target.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { TestDbFlags } from "./test-db.command-handler.ts"; -import { - TestDbEnablePgtapError, - TestDbMutuallyExclusiveFlagsError, - TestDbNoTestsError, - TestDbRunError, -} from "./test-db.errors.ts"; +import { TestDbEnablePgtapError, TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; import { buildPgProveArgs, pgProveMajor } from "./test-db.pg-prove-args.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { @@ -47,7 +40,6 @@ const DISABLE_PGTAP = "drop extension if exists pgtap"; // Compose pin: the config schema has no `[images]` override. Stack prove uses // catalog `pg_prove` instead. Re-verify `NO_TESTS_VERDICT` when bumping this tag. const PG_PROVE_IMAGE = "supabase/pg_prove:3.36"; -const MAX_PROJECT_ID_LENGTH = 40; /** * `pg_prove` exits 0 even when it finds nothing to run, so a typo'd path or a * misresolved bind can silently report success on zero tests. Detecting @@ -121,13 +113,6 @@ const managedStackFor = Effect.fn("test.db.managedStack")(function* () { return { stack, database, connection, pin } satisfies ManagedStack; }); -function sanitizeProjectId(src: string): string { - return src - .replace(/[^a-zA-Z0-9_.-]+/g, "_") - .replace(/^[_.-]+/, "") - .slice(0, MAX_PROJECT_ID_LENGTH); -} - export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; @@ -141,39 +126,17 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const dnsResolver = yield* DnsResolverFlag; - const cliArgs = yield* CliArgs; yield* Effect.gen(function* () { - // Selection is keyed off flag presence, not its boolean value: `--linked=false` - // and `--no-linked` both count as explicitly setting `linked`. - const target = resolveDbTargetFlags(cliArgs.args); - const { setFlags } = target; - if (setFlags.length > 1) { - return yield* new TestDbMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }); - } - - const connType = target.connType ?? "local"; + const target = yield* resolveTarget(flags); - // `--project-ref` never implies `--linked`; see push.handler.ts's - // identical guard (db push) for the rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new TestDbMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - const { conn, isLocal } = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const resolved = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); - yield* warnDeprecatedOrioleDBVersionEnv( - yield* envOverrideLookup(yield* loadProjectEnv(fs, path, cliSettings.workdir)), - ); + const { conn, isLocal } = resolved; const args = buildPgProveArgs({ paths: flags.paths, @@ -184,7 +147,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const backend = yield* currentStackBackend; const managedStack = - backend.kind === "stack" && connType === "local" ? yield* managedStackFor() : undefined; + backend.kind === "stack" && target.kind === "local" ? yield* managedStackFor() : undefined; const proveRuntime = backend.kind === "stack" && managedStack === undefined ? yield* resolveBundledPostgresRuntime(undefined, runtimeInfo.platform, runtimeInfo.arch) @@ -200,7 +163,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { managedStack !== undefined ? proveConnection.host : useNativeProve - ? connType === "local" + ? target.kind === "local" ? "127.0.0.1" : conn.host : stackPublishedProve @@ -230,13 +193,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { : isLocal && backend.kind !== "stack" ? yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); - // The project id is sanitized unconditionally before deriving the - // network name, so a configured `project_id` like "my project" joins - // the same sanitized network the local stack created. - const projectId = sanitizeProjectId( - Option.getOrElse(toml.projectId, () => path.basename(cliSettings.workdir)), - ); - return { _tag: "named" as const, name: `supabase_network_${projectId}` }; + return { _tag: "named" as const, name: `supabase_network_${toml.projectId}` }; }) : { _tag: "host" as const }; @@ -246,13 +203,17 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { let lastVerdict = ""; let lastSummary = ""; + yield* requireTargetMutationConsent(resolved, { + action: "Run pgTAP tests", + details: [ + "enable the pgtap extension", + `run pg_prove against ${args.hostPaths.join(", ")}`, + "drop the pgtap extension afterwards unless it was already installed", + ], + }); + const { exitCode } = yield* Effect.scoped( Effect.gen(function* () { - // stdout is reserved for the pg_prove TAP stream (forwarded byte-exact - // below), so connection diagnostics go to stderr. An `Output.task` - // spinner would also corrupt the stream: it writes ANSI to stdout in - // text mode, and JSON log events to stdout in stream-json mode. - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); // Detect pre-existence before enabling so the drop is skipped when pgTAP diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index dbcc29c2e1..fee3b71a46 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -1,6 +1,16 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -10,7 +20,7 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); -import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; import { VALID_REF, VALID_TOKEN, @@ -18,6 +28,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; +import { branchListApi } from "../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -27,10 +38,17 @@ import { stubManagementApi, type StubbedRequest, } from "../../tests/helpers/permission-real-layers.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../tests/helpers/db-target-selection.ts"; +import { describeHostedConsent } from "../../tests/helpers/hosted-consent.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; +import { HostedMutationDeclinedError } from "./hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; @@ -64,28 +82,40 @@ const stackApiStub = Layer.succeed(StackApi, { }); function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { - const calls: Array<{ - readonly connType: string; - readonly linkedProjectRef: Option.Option; - }> = []; + const inputs: Array = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { - calls.push({ - connType: flags.connType ?? "", - linkedProjectRef: flags.linkedProjectRef ?? Option.none(), - }); - return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }); + resolveConnection: (input: DbConnectionInput) => { + inputs.push(input); + return Effect.succeed({ + conn: opts.conn ?? LOCAL_CONN, + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get calls() { - return calls; + get inputs() { + return inputs; }, }; } +const projectRefLayer = Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("resolve is not used by this test"), + resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 + ? { ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false } + : { ref: VALID_REF, source: "file" as const, matchesLinkedRef: true }, + ), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + promptProjectRef: () => Effect.succeed(VALID_REF), +}); + function mockDbConnection(opts: { existed?: boolean; connectFails?: boolean; @@ -261,20 +291,27 @@ interface SetupOpts { workdir?: string; dnsResolver?: "native" | "https"; dockerInstalled?: boolean; - /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ - args?: ReadonlyArray; /** Runs the real `DbConfigResolver` against `workdir`. */ realLayers?: boolean; + /** `--yes`; on by default so hosted runs reach pg_prove. */ + yes?: boolean; + interactive?: boolean; + confirm?: ReadonlyArray; } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ + format: opts.format ?? "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirm, + }); const telemetry = mockTelemetryStateTracked(); const resolver = mockResolver({ conn: opts.conn, isLocal: opts.isLocal }); const connection = mockDbConnection(opts); const docker = mockDockerRun(opts); const bundled = mockBundledPostgresClient(opts); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, out.layer, opts.realLayers === true ? realDbConfigResolver({ @@ -296,17 +333,25 @@ function setup(opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(DnsResolverFlag, opts.dnsResolver ?? "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), + projectRefLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: opts.interactive ?? false }), BunServices.layer, ); return { layer, out, telemetry, connection, docker, bundled, resolver }; } +const REMOTE_LINKED = Option.some({ _tag: "Linked" } as const); + const flags = (over: Partial[0]> = {}) => ({ paths: over.paths ?? [], + remote: over.remote ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), }); @@ -414,10 +459,11 @@ describe("test db integration", () => { const { layer, docker, connection } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); + yield* testDb( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); const run = docker.lastOpts; expect(run?.network).toEqual({ _tag: "host" }); expect(run?.env["PGHOST"]).toBe(REMOTE_CONN.host); @@ -431,13 +477,14 @@ describe("test db integration", () => { const { layer, docker, bundled } = setup({ conn: LOCAL_CONN, isLocal: true, - args: ["--db-url=postgresql://postgres:postgres@127.0.0.1:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( flags({ - local: false, - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:54322/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + }), }), ); expect(docker.lastOpts).toBeUndefined(); @@ -449,18 +496,19 @@ describe("test db integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("stack --db-url native keeps PGHOST when isLocal is true", () => { + it.live("stack --remote= native keeps PGHOST when isLocal is true", () => { const { layer, docker, bundled } = setup({ conn: { ...LOCAL_CONN, host: "db.internal" }, isLocal: true, dockerInstalled: false, - args: ["--db-url=postgresql://postgres:postgres@db.internal:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( flags({ - dbUrl: Option.some("postgresql://postgres:postgres@db.internal:54322/postgres"), - local: false, + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@db.internal:54322/postgres", + }), }), ); expect(docker.lastOpts).toBeUndefined(); @@ -469,15 +517,14 @@ describe("test db integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("stack --linked native keeps the remote PGHOST", () => { + it.live("stack --remote native keeps the remote PGHOST", () => { const { layer, docker, bundled } = setup({ conn: REMOTE_CONN, isLocal: false, dockerInstalled: false, - args: ["--linked"], }); return Effect.gen(function* () { - yield* testDb(flags({ local: false, linked: true })); + yield* testDb(flags({ remote: REMOTE_LINKED })); expect(docker.lastOpts).toBeUndefined(); expect(bundled.lastOpts?.runtime).toEqual({ kind: "native" }); expect(bundled.lastOpts?.env?.["PGHOST"]).toBe(REMOTE_CONN.host); @@ -489,10 +536,11 @@ describe("test db integration", () => { conn: REMOTE_CONN, isLocal: false, dnsResolver: "https", - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); + yield* testDb( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); }).pipe(Effect.provide(layer)); }); @@ -647,7 +695,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("json mode: streams TAP only — emits no result envelope (Go parity)", () => { + it.live("json mode: streams TAP only — emits no result envelope", () => { const { layer, out } = setup({ format: "json", exitCode: 0 }); return Effect.gen(function* () { yield* testDb(flags()); @@ -655,7 +703,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stream-json mode: emits no result envelope (Go parity)", () => { + it.live("stream-json mode: emits no result envelope", () => { const { layer, out } = setup({ format: "stream-json", exitCode: 0 }); return Effect.gen(function* () { yield* testDb(flags()); @@ -663,69 +711,6 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects mutually exclusive connection flags (--linked + --local via args)", () => { - const { layer } = setup({ args: ["--linked", "--local"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(encodeJson(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }); - - it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(encodeJson(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }); - - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - const { layer } = setup({ args: ["--linked=false"] }); - // No explicit assertion: success means routing reached resolver.resolve - // with connType "linked" instead of failing on mutual exclusion or the - // local-target guard. - return testDb(flags()).pipe(Effect.provide(layer)); - }); - - it.live("tests the project given via --project-ref --linked", () => { - // Only reaches the resolver as `linkedProjectRef` when --linked is set. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false, args: ["--linked"] }); - return Effect.gen(function* () { - yield* testDb(flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) })); - expect(resolver.calls[0]?.connType).toBe("linked"); - expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); - }).pipe(Effect.provide(layer)); - }); - - it.live("rejects --project-ref on the default local target", () => { - // No explicit --local/--db-url flag; the guard must fire from the default alone. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, connection, docker, resolver } = setup(); - return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags({ projectRef: Option.some(FLAG_REF) }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(encodeJson(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } - expect(resolver.calls).toEqual([]); - expect(connection.execCalls).toEqual([]); - expect(docker.lastOpts).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - it.live("honors --network-id, overriding the generated local network name", () => { const { layer, docker } = setup({ networkId: "my-custom-net" }); return Effect.gen(function* () { @@ -742,30 +727,52 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the connection diagnostic to stderr, keeping stdout for TAP (Go parity)", () => { + it.live("keeps stdout for the TAP stream only", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* testDb(flags()); - expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stdoutText).toBe(""); expect(out.messages).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("labels the connection diagnostic 'remote' for non-local connections", () => { - const { layer, out } = setup({ + it.live("asks before enabling pgTAP on a hosted target and stops when declined", () => { + const { layer, out, connection, docker } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], + yes: false, + interactive: true, + confirm: [false], }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); - expect(out.stderrText).toContain("Connecting to remote database..."); + const exit = yield* Effect.exit( + testDb( + flags({ + remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const), + paths: ["/abs/a_test.sql"], + }), + ), + ); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + HostedMutationDeclinedError, + ); + expect(out.stderrText).toContain("enable the pgtap extension"); + expect(out.stderrText).toContain("run pg_prove against /abs/a_test.sql"); + expect(connection.connectCalls).toEqual([]); + expect(docker.lastOpts).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs against a local target without asking for consent", () => { + const { layer, out } = setup({ yes: false }); + return Effect.gen(function* () { + yield* testDb(flags()); + expect(out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); const tempWorkdir = useTempWorkdir(); - it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { + it.live("sanitizes a configured project_id when naming the local network", () => { const workdir = tempWorkdir.current; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -786,16 +793,15 @@ describe("test db integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); - it.effect("mints a login role through the real resolver for --linked --project-ref", () => { + it.effect("mints a login role through the real resolver for --remote=", () => { const { layer, connection } = setup({ - args: ["test", "db", "--linked", "--project-ref", VALID_REF], realLayers: true, workdir: tempWorkdir.current, }); const requests: Array = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* testDb(flags({ linked: true, local: false, projectRef: Option.some(VALID_REF) })); + yield* testDb(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) })); expect(connection.connectCalls[0]?.isLocal).toBe(false); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -803,7 +809,7 @@ describe("test db integration", () => { ]); assertPermissionDrift({ command: "test db", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -816,7 +822,6 @@ describe("test db integration", () => { it.effect("makes no Management API request for the default local target", () => { const { layer } = setup({ - args: ["test", "db"], realLayers: true, workdir: tempWorkdir.current, }); @@ -847,4 +852,53 @@ describe("test db integration", () => { provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); }); + + describeDbTargetSelection("test db", { + dbUrl: true, + workdir: () => tempWorkdir.current, + attempt: ({ workdir, target }) => { + const ctx = setup({ conn: REMOTE_CONN, workdir }); + const branchApi = branchListApi(); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: testDb(flags(target)), + target: () => ctx.resolver.inputs[0]?.target, + branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, + }; + }, + }); +}); + +describeHostedConsent("test db", { + invoke: () => { + const connection = mockDbConnection({}); + return { + run: testDb( + flags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + paths: ["supabase/tests"], + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + mockResolver({ conn: REMOTE_CONN, isLocal: false }).layer, + connection.layer, + mockDockerRun({}).layer, + mockBundledPostgresClient({}).layer, + stackApiStub, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + runtimeInfoLayer("linux"), + Layer.succeed(DebugFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + Effect.scoped, + ), + mutations: () => connection.execCalls.length, + }; + }, }); diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 2ba9df6c0f..5896566793 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; +import { commandCredentialsLayer } from "../auth/command-credentials.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; +import { projectRefLayer } from "../config/project-ref.layer.ts"; import { dbConfigLayer } from "./db-config.layer.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { dockerRunLayer } from "./docker-run.layer.ts"; @@ -27,6 +30,23 @@ import { bundledPostgresClientLayer } from "./bundled-postgres-client.ts"; */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +const projectRef = projectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliSettings), +); + const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), @@ -41,6 +61,8 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray) => dbConnectionLayer, dockerRunLayer, cliSettings, + projectRef, + platformApiFactory, // Exposed at top level so `withCommandTelemetry` can read // `stitchedDistinctId()`. The same reference is provided to `dbConfig` // above, so the lazy linked stack shares a single stitch attempt. diff --git a/apps/cli/src/command-internal/test-db.layers.unit.test.ts b/apps/cli/src/command-internal/test-db.layers.unit.test.ts index d4943a57f1..fd37653934 100644 --- a/apps/cli/src/command-internal/test-db.layers.unit.test.ts +++ b/apps/cli/src/command-internal/test-db.layers.unit.test.ts @@ -23,6 +23,7 @@ import { mockTelemetryStateLayer, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { @@ -38,6 +39,7 @@ import { DbConnection } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; import { testDbRuntimeLayer } from "./test-db.layers.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-test-db-layers-"); @@ -67,7 +69,7 @@ function ambientStubs() { connect: () => Effect.die("db-connection not needed for layer-exposure test"), }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), }), @@ -81,8 +83,10 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockTelemetryStateLayer, + cliConfigValuesTestLayer, heavyServiceStubs, ); } diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index cea54e8411..e28679863c 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -4,19 +4,21 @@ import { FetchHttpClient } from "effect/http"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted } from "effect"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { CommandRuntime } from "../shared/runtime/command-runtime.service.ts"; -import { mockOutput, mockProcessControl } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; import { parseConnectionString } from "./db-config.parse.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; import { testDb } from "./test-db.handler.ts"; @@ -27,6 +29,7 @@ import type { InitializationCommandOptions, PostgresCommandOptions } from "@supa import type { Stack } from "@supabase/stack/effect"; import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { selection } from "../../tests/helpers/pins.ts"; const runtimes = ["native", "docker"] as const; @@ -45,6 +48,7 @@ const databaseCreation = { const flags = (path: string) => ({ paths: [path], + remote: Option.none(), dbUrl: Option.none(), linked: false, local: true, @@ -102,7 +106,13 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { output.layer, mockTelemetryStateTracked().layer, Layer.succeed(DbConfigResolver, { - resolve: () => Effect.succeed({ conn: connection, isLocal: true }), + resolveConnection: (input) => + Effect.succeed({ + conn: connection, + isLocal: true, + ref: Option.none(), + target: input.target, + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(RuntimeInfo, { @@ -117,6 +127,17 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { Layer.succeed(DnsResolverFlag, "native" as const), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, false), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: false }), + Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("unused"), + resolveForLink: () => Effect.die("unused"), + resolveRef: () => Effect.die("unused"), + resolveOptional: () => Effect.die("unused"), + promptProjectRef: () => Effect.die("unused"), + }), Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), Layer.succeed(DockerRun, { run: () => Effect.die("unused"), @@ -125,6 +146,7 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { }), stackBackendLayer("stack"), dbConnectionLayer, + cliConfigValuesTestLayer, ); const assertPgtapDropped = Effect.gen(function* () { const dbConnection = yield* DbConnection; @@ -155,7 +177,6 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { } } expect(failedOutput.stdoutText).toContain("not ok"); - expect(failedOutput.stderrText).toContain("Connecting to local database"); yield* assertPgtapDropped.pipe(Effect.provide(services)); if (runtime !== "native") return; diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json new file mode 100644 index 0000000000..69487a0395 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json new file mode 100644 index 0000000000..b3411f8000 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json new file mode 100644 index 0000000000..69487a0395 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json new file mode 100644 index 0000000000..42ed444410 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 56001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json new file mode 100644 index 0000000000..a8c7202210 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json @@ -0,0 +1,53 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "dotenv_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "dotenv_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 57001, + "projectEnv": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_DB_MIGRATIONS_ENABLED": "false", + "SUPABASE_DB_PORT": "57001", + "SUPABASE_DB_SEED_ENABLED": "false" + }, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json new file mode 100644 index 0000000000..42ed444410 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 56001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json new file mode 100644 index 0000000000..5dbaa54e7d --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/shell-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 56001, + "projectEnv": {}, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json new file mode 100644 index 0000000000..3e04de121e --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json @@ -0,0 +1,53 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "dotenv_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "dotenv_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 57001, + "projectEnv": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_DB_MIGRATIONS_ENABLED": "false", + "SUPABASE_DB_PORT": "57001", + "SUPABASE_DB_SEED_ENABLED": "false" + }, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json new file mode 100644 index 0000000000..2f7cf1d7e3 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "zyxwvutsrqponmlkjihg", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json new file mode 100644 index 0000000000..2f7cf1d7e3 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "zyxwvutsrqponmlkjihg", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json new file mode 100644 index 0000000000..69487a0395 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json new file mode 100644 index 0000000000..6450d106a5 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": false, + "storageEnabled": false, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json new file mode 100644 index 0000000000..62551b56f7 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "shell-project-id", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json new file mode 100644 index 0000000000..73b419dc35 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json @@ -0,0 +1,48 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "port": 55001, + "projectEnv": {}, + "projectId": "shell-project-id", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json new file mode 100644 index 0000000000..31aa4d291e --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json @@ -0,0 +1,711 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,base_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": false, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://base.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "ipVersion": "IPv4", + "maxHeaderLength": 4096 + }, + "endpoints": { + "http": { + "port": 55321 + }, + "rpc": { + "port": "auto" + } + }, + "service": "realtime" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,base_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json new file mode 100644 index 0000000000..1ad43f1575 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json @@ -0,0 +1,680 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 57322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,dotenv_api" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://dotenv.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 57321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": { + "SUPABASE_API_PORT": "57321", + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STUDIO_ENABLED": "false" + }, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 57321, + "schemas": [ + "public", + "dotenv_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://dotenv.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json new file mode 100644 index 0000000000..4a2f20f873 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json @@ -0,0 +1,1125 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,base_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": false, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://base.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "ipVersion": "IPv4", + "maxHeaderLength": 4096 + }, + "endpoints": { + "http": { + "port": 55321 + }, + "rpc": { + "port": "auto" + } + }, + "service": "realtime" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,base_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": { + "prod": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 54321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false + }, + "custom_access_token": { + "enabled": false + }, + "mfa_verification_attempt": { + "enabled": false + }, + "password_verification_attempt": { + "enabled": false + }, + "send_email": { + "enabled": false + }, + "send_sms": { + "enabled": false + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 54322, + "seed": { + "enabled": true, + "sql_paths": [ + "./seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + } + }, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json new file mode 100644 index 0000000000..4b6d8a4fcf --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json @@ -0,0 +1,672 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 56322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 56321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 56321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json new file mode 100644 index 0000000000..da40f6b4d6 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json @@ -0,0 +1,696 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,remote_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://remote.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,remote_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json new file mode 100644 index 0000000000..e8476e18d0 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json @@ -0,0 +1,678 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 57322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 57321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": { + "SUPABASE_API_PORT": "57321", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STUDIO_ENABLED": "false" + }, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 57321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json new file mode 100644 index 0000000000..41a7615602 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json @@ -0,0 +1,672 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {} + }, + "endpoints": { + "sql": { + "port": 56322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/stacks/golden-stack/uploads", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 56321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 56321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/timestamp.format.unit.test.ts b/apps/cli/src/command-internal/timestamp.format.unit.test.ts index c8bd2c1af8..ef79682c44 100644 --- a/apps/cli/src/command-internal/timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/timestamp.format.unit.test.ts @@ -14,7 +14,7 @@ describe("formatTimestamp", () => { }); it("falls back to the original value for already-formatted timestamps", () => { - // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + // RFC3339 parsing rejects "2026-02-08 16:44:07" (space, not T). expect(formatTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); }); diff --git a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts index 34232a2d5c..417bdaad54 100644 --- a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts @@ -82,7 +82,6 @@ describe("upgrade notice user-level cache", () => { [...(options.args ?? []), "projects", "list"], { cleanShowHelp: false, - delegatedToGo: false, workingDirectory: options.workingDirectory ?? workdir, isValueTakingFlagToken: () => false, }, diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index 57c60cadfb..9ec18c5941 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -29,6 +29,7 @@ import { rootFlagTokens, } from "../shared/cli/run.ts"; import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION, parseSemver } from "../shared/cli/version.ts"; +import { candidateDotenvFilenames } from "../shared/config/cli-config-env.ts"; import { resolveSupabaseHomeValue } from "../shared/config/supabase-home.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { @@ -37,10 +38,9 @@ import { ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; import { bold, yellow } from "./colors.ts"; -import { legacyProjectStatePaths } from "./legacy-project-state.paths.ts"; -import { parseDotEnv } from "./dotenv.ts"; -import { candidateDotenvFilenames } from "./project-environment.ts"; -import { projectStateMode } from "./project-state-mode.ts"; +import { legacyProjectStatePaths } from "../shared/config/legacy-project-state.paths.ts"; +import { parseDotEnv } from "../shared/config/dotenv.ts"; +import { projectStateMode } from "../shared/config/project-state-mode.ts"; const LATEST_RELEASE_URL = "https://api.github.com/repos/supabase/cli/releases/latest"; const CACHE_TTL_MS = 10 * 60 * 60 * 1000; @@ -208,8 +208,10 @@ const resolveNoticeBaseDir = Effect.fnUntraced(function* ( } let current = cwd; while (true) { - const configToml = path.join(current, "supabase", "config.toml"); - if (yield* fs.exists(configToml).pipe(Effect.orElseSucceed(() => false))) return current; + for (const configFile of ["config.json", "config.toml"]) { + const configPath = path.join(current, "supabase", configFile); + if (yield* fs.exists(configPath).pipe(Effect.orElseSucceed(() => false))) return current; + } const parent = path.dirname(current); if (parent === current) return cwd; current = parent; @@ -219,7 +221,7 @@ const resolveNoticeBaseDir = Effect.fnUntraced(function* ( /** * The project dotenv chain as a merged map: `/supabase` then ``, * first file to define a key wins, shell env always beats a chain value — - * same precedence as `resolveProjectEnvironmentValues`. Read for every real + * same precedence as `loadCliProjectEnvFiles`. Read for every real * command since this hook can't tell whether the command loads config; the * only effect is a suppressed notice or extra debug diagnostic either way. */ @@ -238,7 +240,8 @@ const projectDotenvValues = Effect.fnUntraced(function* ( : [path.join(base, "supabase"), base]; for (const dir of dirs) { for (const filename of candidateDotenvFilenames(env["SUPABASE_ENV"] || "development")) { - const contents = yield* readUtf8(fs, path.join(dir, filename)).pipe(Effect.option); + // `readFileString` drops a leading byte-order mark, as the main loader's reader does. + const contents = yield* fs.readFileString(path.join(dir, filename)).pipe(Effect.option); if (Option.isNone(contents)) continue; const parsed = Result.try(() => parseDotEnv(contents.value)); // A malformed file is only reachable here when the command never @@ -438,7 +441,6 @@ const NOTICE_ENV_KEYS = [ "SUPABASE_DEBUG", "SUPABASE_WORKDIR", "SUPABASE_ENV", - "SUPABASE_EXPERIMENTAL_STACK", ]; /** @@ -449,7 +451,6 @@ export const upgradeNoticeHook = ( args: ReadonlyArray, info: { readonly cleanShowHelp: boolean; - readonly delegatedToGo: boolean; readonly workingDirectory?: string; readonly isValueTakingFlagToken: (token: string) => boolean; }, @@ -459,52 +460,46 @@ export const upgradeNoticeHook = ( HttpClient.HttpClient > = fetchLatestReleaseTag, ): Effect.Effect => - info.delegatedToGo - ? Effect.void - : Effect.gen(function* () { - const path = yield* Path.Path; - const clock = yield* Clock.Clock; - const runtime = yield* RuntimeInfo; - const env: Record = {}; - for (const key of NOTICE_ENV_KEYS) { - env[key] = Option.getOrUndefined(yield* Config.option(Config.String(key))); - } - const supabaseHome = yield* Config.option(Config.String("SUPABASE_HOME")); - const outcome = yield* runUpgradeNotice({ - env, - args, - cleanShowHelp: info.cleanShowHelp, - isValueTakingFlagToken: info.isValueTakingFlagToken, - cwd: runtime.cwd, - resolvedCwd: info.workingDirectory, - currentVersion: CLI_VERSION, - supabaseHome: resolveSupabaseHomeValue(path, supabaseHome, runtime.homeDir), - /* An unreadable mode keeps the cache out of the project; a relative `--workdir` resolves against the process cwd. */ - usesLegacyProjectState: projectStateMode( - info.workingDirectory === undefined - ? { args, cwd: runtime.cwd, env } - : { - cwd: info.workingDirectory, - env: { SUPABASE_EXPERIMENTAL_STACK: env["SUPABASE_EXPERIMENTAL_STACK"] }, - }, - ).pipe( - Effect.map((mode) => mode === "legacy"), - Effect.orElseSucceed(() => false), - ), - now: () => clock.currentTimeMillisUnsafe(), - /* Runs inside the check span, so the fetch is its child span. */ - fetchLatestTag: fetchLatestTag.pipe( - Effect.provide(FetchHttpClient.layer), - Effect.withSpan("UpgradeNotice.fetch"), - ), - writeStderr: (text) => { - process.stderr.write(text); - }, - }); - if (outcome !== undefined) { - yield* Effect.annotateCurrentSpan({ - "cache.location": outcome.cache, - "cache.hit": outcome.cacheFresh, - }); - } - }).pipe(Effect.withSpan("UpgradeNotice.check"), Effect.ignoreCause); + Effect.gen(function* () { + const path = yield* Path.Path; + const clock = yield* Clock.Clock; + const runtime = yield* RuntimeInfo; + const env: Record = {}; + for (const key of NOTICE_ENV_KEYS) { + env[key] = Option.getOrUndefined(yield* Config.option(Config.String(key))); + } + const supabaseHome = yield* Config.option(Config.String("SUPABASE_HOME")); + const outcome = yield* runUpgradeNotice({ + env, + args, + cleanShowHelp: info.cleanShowHelp, + isValueTakingFlagToken: info.isValueTakingFlagToken, + cwd: runtime.cwd, + resolvedCwd: info.workingDirectory, + currentVersion: CLI_VERSION, + supabaseHome: resolveSupabaseHomeValue(path, supabaseHome, runtime.homeDir), + /* An unreadable mode keeps the cache out of the project. */ + usesLegacyProjectState: projectStateMode({ + args: info.workingDirectory === undefined ? args : ["--workdir", info.workingDirectory], + cwd: runtime.cwd, + }).pipe( + Effect.map((mode) => mode === "legacy"), + Effect.orElseSucceed(() => false), + ), + now: () => clock.currentTimeMillisUnsafe(), + /* Runs inside the check span, so the fetch is its child span. */ + fetchLatestTag: fetchLatestTag.pipe( + Effect.provide(FetchHttpClient.layer), + Effect.withSpan("UpgradeNotice.fetch"), + ), + writeStderr: (text) => { + process.stderr.write(text); + }, + }); + if (outcome !== undefined) { + yield* Effect.annotateCurrentSpan({ + "cache.location": outcome.cache, + "cache.hit": outcome.cacheFresh, + }); + } + }).pipe(Effect.withSpan("UpgradeNotice.check"), Effect.ignoreCause); diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 04a152e3e6..c215641698 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -20,7 +20,7 @@ import { } from "./upgrade-notice.ts"; describe("updateNotifierDisabled", () => { - it.each(["1", "t", "T", "TRUE", "true", "True"])("suppresses for ParseBool-true %j", (value) => { + it.each(["1", "t", "T", "TRUE", "true", "True"])("suppresses for boolean-true %j", (value) => { expect(updateNotifierDisabled(value)).toBe(true); }); @@ -65,7 +65,7 @@ describe("isNewerCliVersion", () => { }); describe("formatUpgradeNotice", () => { - it("matches the Go CLI's message bytes modulo styling", () => { + it("emits the expected message bytes modulo styling", () => { expect(stripVTControlCharacters(formatUpgradeNotice("v2.114.0", "2.113.0"))).toBe( "A new version of Supabase CLI is available: v2.114.0 (currently installed v2.113.0)\n" + "We recommend updating regularly for new features and bug fixes: " + @@ -756,6 +756,19 @@ layer(BunServices.layer)("runUpgradeNotice", (it) => { }), ); + it.effect("a project dotenv with a byte-order mark still opts out", () => + Effect.gen(function* () { + const ctx = yield* setup({}); + yield* ctx.fs.writeFileString( + ctx.path.join(ctx.workdir, "supabase", ".env"), + `${String.fromCharCode(0xfeff)}SUPABASE_NO_UPDATE_NOTIFIER=1\n`, + ); + yield* notice(ctx.deps); + expect(ctx.stderr).toBe(""); + expect(ctx.fetchCalls).toBe(0); + }), + ); + it.effect("keeps a leading BOM in the cached tag, so it never reads as a release", () => Effect.gen(function* () { const ctx = yield* setup({ cacheContent: "\uFEFFv99.99.99", cacheAgeMs: 60 * 1000 }); @@ -837,17 +850,8 @@ const captureStderr = (body: Effect.Effect) => }), ); -/** - * `delegatedToGo` is asserted directly rather than via a real proxied - * command, so this doesn't depend on which commands still delegate to Go — - * that set only shrinks (`docs/go-cli-porting-status.md`), and a test pinned - * to one command would break the moment it's ported. - */ layer(hookLayer)("upgradeNoticeHook", (it) => { - const runHook = Effect.fnUntraced(function* (opts: { - readonly delegatedToGo?: boolean; - readonly cached?: boolean; - }) { + const runHook = Effect.fnUntraced(function* (opts: { readonly cached?: boolean }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-hook-" }); @@ -875,7 +879,6 @@ layer(hookLayer)("upgradeNoticeHook", (it) => { ["db", "branch", "list"], { cleanShowHelp: false, - delegatedToGo: opts.delegatedToGo === true, workingDirectory: workdir, isValueTakingFlagToken: () => false, }, @@ -895,12 +898,6 @@ layer(hookLayer)("upgradeNoticeHook", (it) => { }), ); - it.effect("stays silent when the run delegated to Go, which printed its own notice", () => - Effect.gen(function* () { - expect((yield* runHook({ delegatedToGo: true })).stderr).toBe(""); - }), - ); - it.effect("traces a cache hit on the check span without a fetch span", () => Effect.gen(function* () { const { span } = yield* runHook({}); @@ -974,7 +971,6 @@ layer(hookLayer)("upgradeNoticeHook release lookup", (it) => { }, upgradeNoticeHook(["db", "branch", "list"], { cleanShowHelp: false, - delegatedToGo: false, workingDirectory: opts.workdirFromEnv === true ? undefined : workdir, isValueTakingFlagToken: () => false, }), diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index e45dfec831..23202f3593 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -14,6 +14,7 @@ import { PropOrgSlug, } from "../shared/telemetry/event-catalog.ts"; import { Output } from "../shared/output/output.service.ts"; +import { parsePlanGateEnvelope } from "./http-errors.ts"; import { billingUrl } from "./profile.ts"; import { block } from "./command-permissions/model.ts"; @@ -33,33 +34,6 @@ export const upgradeSuggestion = block("upgrade-suggest", [ }, ]); -function readString(obj: unknown, key: string): string { - if (typeof obj === "object" && obj !== null && key in obj) { - const value = (obj as Record)[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - -interface PlanGateEnvelope { - readonly feature: string; - readonly upgradeUrl: string; -} - -// Hand-validated: the packages/api codegen emits no non-2xx schemas. -function parsePlanGateEnvelope(body: unknown): PlanGateEnvelope | undefined { - if (typeof body !== "object" || body === null) return undefined; - const error = (body as { readonly error?: unknown }).error; - if (typeof error !== "object" || error === null) return undefined; - const code = readString(error, "code"); - const feature = readString(error, "feature"); - const upgradeUrl = readString(error, "upgrade_url"); - if (code !== "entitlement_required" || feature === "" || upgradeUrl === "") { - return undefined; - } - return { feature, upgradeUrl }; -} - function orgSlugFromUpgradeUrl(upgradeUrl: string): string { const match = /\/org\/([^/]+)/.exec(upgradeUrl); return match?.[1] ?? ""; @@ -138,9 +112,9 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: const envelope = Option.isSome(body) ? parsePlanGateEnvelope(body.value) : undefined; if (envelope !== undefined) { gate = { - billingUrl: envelope.upgradeUrl, + billingUrl: envelope.upgrade_url, feature: envelope.feature, - orgSlug: orgSlugFromUpgradeUrl(envelope.upgradeUrl), + orgSlug: orgSlugFromUpgradeUrl(envelope.upgrade_url), }; } } diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index 9e73fbeeae..507484650d 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -6,6 +6,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { configParseCauseMessage } from "../shared/output/normalize-error.ts"; import { sanitizeInlineName } from "./http-errors.ts"; /** Renders a config-load path relative to `workdir` (e.g. `supabase/config.json`), regardless of invocation cwd. */ @@ -13,6 +14,13 @@ export function relativeConfigPath(workdir: string, path: string): string { return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; } +export function describeConfigParseError( + workdir: string, + error: { readonly path: string; readonly cause: unknown }, +): string { + return `failed to parse ${relativeConfigPath(workdir, error.path)}: ${configParseCauseMessage(error.cause)}`; +} + /** * Shared "no project here" message for JSON-capable config loads (`config diff/push/pull`, * `gen types`, `storage ls|mv|rm|cp`, `seed buckets`). @@ -70,8 +78,8 @@ class WorkdirProjectMissingError extends Data.TaggedError("WorkdirProjectMissing * Fails when an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project; a no-op for a defaulted * workdir. * - * Only valid for callers that don't pass `tomlOnly: true` to `loadCliConfig` — that mode can - * return `null` for a `config.json`-only project even though this probe succeeds. + * Valid for any caller that loads config through the resolved config, which probes `config.json` and + * `config.toml` alike. */ export const requireExplicitWorkdirProject = Effect.fnUntraced(function* (cliSettings: { readonly workdir: string; diff --git a/apps/cli/src/command-internal/workdir-project.unit.test.ts b/apps/cli/src/command-internal/workdir-project.unit.test.ts index e9beef0fd0..d82095139a 100644 --- a/apps/cli/src/command-internal/workdir-project.unit.test.ts +++ b/apps/cli/src/command-internal/workdir-project.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { missingProjectConfigMessage } from "./workdir-project.ts"; +import { describeConfigParseError, missingProjectConfigMessage } from "./workdir-project.ts"; describe("missingProjectConfigMessage", () => { it("points at `supabase init` for a defaulted workdir", () => { @@ -29,3 +29,20 @@ describe("missingProjectConfigMessage", () => { expect(message).not.toContain(""); }); }); + +describe("describeConfigParseError", () => { + it("names the failed file relative to the workdir", () => { + expect( + describeConfigParseError("/repo", { + path: "/repo/supabase/config.toml", + cause: new Error("unexpected end of input"), + }), + ).toBe("failed to parse supabase/config.toml: unexpected end of input"); + }); + + it("keeps a path outside the workdir as given", () => { + expect( + describeConfigParseError("/repo", { path: "/elsewhere/config.json", cause: "bad json" }), + ).toBe("failed to parse /elsewhere/config.json: bad json"); + }); +}); diff --git a/apps/cli/src/command-internal/workdir-search.ts b/apps/cli/src/command-internal/workdir-search.ts deleted file mode 100644 index 70619008fe..0000000000 --- a/apps/cli/src/command-internal/workdir-search.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Whether a config load should climb ancestor directories for `supabase/config.{toml,json}` - * beyond `CommandSettings.workdir`. - * - * An explicit workdir is authoritative and never climbs, so an unrelated ancestor project's - * config can't win. A defaulted workdir still climbs, since its own default resolution only - * probes `config.toml`. - */ -export function shouldSearchAncestors(cliSettings: { readonly explicitWorkdir: boolean }): boolean { - return !cliSettings.explicitWorkdir; -} diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 905f8cfe77..84c1630ae8 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase backups list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -8,7 +10,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/project-ref` | plain text | when neither `--remote=` nor `SUPABASE_PROJECT_ID` is set | ## Files Written @@ -19,38 +21,40 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=` (or the deprecated `--project-ref=`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=` when no project is linked | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--remote=` is not given | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — backup list printed to stdout | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `BackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | -| `1` | `BackupListNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------- | +| `0` | success — backup list printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — no `--remote=`, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | +| `1` | `BackupListNetworkError` — transport-level network failure | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=` → the ref, a bare `--remote` → `true`, any other `--remote` value → ``; `--project-ref` → the ref when ref-shaped, else ``) | ## Output @@ -58,7 +62,7 @@ The `--output {pretty,json,yaml,toml,env}` flag and the `--output-format {text,j ### `--output pretty` (default) / `--output-format text` -For PITR-only projects, prints a Glamour-styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. The table is rendered byte-for-byte using `glamour.WithStandardStyle(styles.AsciiStyle)`. +For PITR-only projects, prints a styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. ### `--output json` @@ -91,6 +95,5 @@ One `result` NDJSON event on success containing the backup response object. ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index eb28f1d634..02262f2b0c 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -1,20 +1,20 @@ import type * as CliCommand from "effect/cli/Command"; -import { Command, Flag } from "effect/cli"; +import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { projectRefPicker } from "../../../config/remote-target.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BackupsListFlags = CliCommand.Command.Config.Infer; @@ -28,7 +28,7 @@ export const backupsListCommand = Command.make("list", config).pipe( description: "List all physical backups", }, { - command: "supabase backups list --project-ref ", + command: "supabase backups list --remote=", description: "List backups for a specific project", }, ]), @@ -37,10 +37,12 @@ export const backupsListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v1-list-all-backups", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => backupsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index 0587fe9d73..70089a15a1 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -2,52 +2,52 @@ import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { RemoteTarget } from "../../../config/remote-target.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { BackupListNetworkError, BackupListUnexpectedStatusError } from "../backups.errors.ts"; -import { encodeEnv, encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; +import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { - encodeGoToml, - encodeGoYaml, - goBool, - goInt, - goPtr, - goSlice, - goString, - goStruct, -} from "../../../command-internal/go-struct-output.encoders.ts"; + encodeStructToml, + encodeStructYaml, + shapeBool, + shapeInt, + shapePtr, + shapeSlice, + shapeString, + shapeStruct, +} from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { formatTimestamp } from "../../../command-internal/timestamp.format.ts"; import { formatRegion } from "../../../command-internal/region.format.ts"; import type { BackupsListFlags } from "./list.command.ts"; /** Struct shape for `-o yaml|toml` encoding of the backups response. */ -const GO_BACKUPS_RESPONSE = goStruct([ +const BACKUPS_RESPONSE_SHAPE = shapeStruct([ [ "backups", - goSlice( - goStruct([ - ["id", goInt], - ["inserted_at", goString], - ["is_physical_backup", goBool], - ["status", goString], + shapeSlice( + shapeStruct([ + ["id", shapeInt], + ["inserted_at", shapeString], + ["is_physical_backup", shapeBool], + ["status", shapeString], ]), ), ], [ "physical_backup_data", - goStruct([ - ["earliest_physical_backup_date_unix", goPtr(goInt)], - ["latest_physical_backup_date_unix", goPtr(goInt)], + shapeStruct([ + ["earliest_physical_backup_date_unix", shapePtr(shapeInt)], + ["latest_physical_backup_date_unix", shapePtr(shapeInt)], ]), ], - ["pitr_enabled", goBool], - ["region", goString], - ["walg_enabled", goBool], + ["pitr_enabled", shapeBool], + ["region", shapeString], + ["walg_enabled", shapeBool], ]); type BackupsResponse = typeof V1ListAllBackupsOutput.Type; @@ -91,13 +91,12 @@ function renderLogicalTable(response: BackupsResponse): string { export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsListFlags) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* RemoteTarget; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const { ref } = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { // Spinner is text-mode only; in JSON/stream-json it would leave a dangling `[task] start:` @@ -110,31 +109,31 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi ); yield* fetching?.clear ?? Effect.void; - const goFmt = Option.getOrUndefined(goOutputFlag); + const outputFlagFormat = Option.getOrUndefined(outputFlag); - if (goFmt === "json") { - yield* output.raw(encodeGoJson(response, { nullForEmptyArrays: ["backups"] })); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson(response, { nullForEmptyArrays: ["backups"] })); return; } - if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(response, GO_BACKUPS_RESPONSE)); + if (outputFlagFormat === "yaml") { + yield* output.raw(encodeStructYaml(response, BACKUPS_RESPONSE_SHAPE)); return; } - if (goFmt === "toml") { + if (outputFlagFormat === "toml") { // Treats an empty backups list as absent, matching the nullForEmptyArrays JSON handling // above. yield* output.raw( - encodeGoToml( + encodeStructToml( { ...response, backups: response.backups.length > 0 ? response.backups : undefined, }, - GO_BACKUPS_RESPONSE, + BACKUPS_RESPONSE_SHAPE, ), ); return; } - if (goFmt === "env") { + if (outputFlagFormat === "env") { yield* output.raw(encodeEnv(response) + "\n"); return; } diff --git a/apps/cli/src/commands/backups/list/list.integration.test.ts b/apps/cli/src/commands/backups/list/list.integration.test.ts index 7b844fa782..2eb3f1a72e 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -12,6 +12,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsList } from "./list.handler.ts"; @@ -60,12 +61,13 @@ const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = { interface SetupOpts { format?: "text" | "json" | "stream-json"; - goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; response?: typeof V1ListAllBackupsOutput.Type; status?: number; network?: "fail"; apiUrl?: string; userAgent?: string; + projectId?: string; } const tempRoot = useTempWorkdir("supabase-backups-list-int-"); @@ -82,12 +84,13 @@ function setup(opts: SetupOpts = {}) { workdir: tempRoot.current, apiUrl: opts.apiUrl, userAgent: opts.userAgent, + ...(opts.projectId === undefined ? {} : { projectId: Option.some(opts.projectId) }), }); const layer = buildTestRuntime({ out, api, cliSettings, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }); return { layer, out, api }; } @@ -96,7 +99,7 @@ describe("backups list integration", () => { it.effect("renders a PITR-only table when no physical backups exist", () => { const { layer, out } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("REGION"); expect(out.stdoutText).toContain("WALG"); expect(out.stdoutText).toContain("PITR"); @@ -110,7 +113,7 @@ describe("backups list integration", () => { it.effect("renders a logical backups table with PHYSICAL classification", () => { const { layer, out } = setup({ response: LOGICAL_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("BACKUP TYPE"); expect(out.stdoutText).toContain("PHYSICAL"); expect(out.stdoutText).toContain("COMPLETED"); @@ -121,7 +124,7 @@ describe("backups list integration", () => { it.effect("translates ap-southeast-1 to Southeast Asia (Singapore)", () => { const { layer, out } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); }).pipe(Effect.provide(layer)); }); @@ -129,7 +132,7 @@ describe("backups list integration", () => { it.effect("emits a JSON success event when --output-format=json", () => { const { layer, out } = setup({ format: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ region: "ap-southeast-1", walg_enabled: true }); @@ -139,17 +142,17 @@ describe("backups list integration", () => { it.effect("emits a result event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ region: "ap-southeast-1" }); }).pipe(Effect.provide(layer)); }); - it.effect("emits indented JSON to stdout for --output json (Go-compat)", () => { - const { layer, out } = setup({ goOutput: "json", response: PITR_RESPONSE }); + it.effect("emits indented JSON to stdout for --output json", () => { + const { layer, out } = setup({ outputFlag: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( `{ "backups": null, @@ -164,27 +167,27 @@ describe("backups list integration", () => { }); it.effect("emits YAML to stdout for --output yaml", () => { - const { layer, out } = setup({ goOutput: "yaml", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "yaml", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); expect(out.stdoutText).toContain("walgenabled: true"); }).pipe(Effect.provide(layer)); }); it.effect("emits TOML to stdout for --output toml", () => { - const { layer, out } = setup({ goOutput: "toml", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "toml", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('Region = "ap-southeast-1"'); expect(out.stdoutText).toContain("WalgEnabled = true"); }).pipe(Effect.provide(layer)); }); it.effect("emits [[Backups]] array-of-tables for --output toml with logical backups", () => { - const { layer, out } = setup({ goOutput: "toml", response: LOGICAL_RESPONSE }); + const { layer, out } = setup({ outputFlag: "toml", response: LOGICAL_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe(`PitrEnabled = true Region = "ap-southeast-1" WalgEnabled = true @@ -201,18 +204,18 @@ WalgEnabled = true }); it.effect("emits KEY=VALUE lines for --output env", () => { - const { layer, out } = setup({ goOutput: "env", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "env", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('REGION="ap-southeast-1"'); expect(out.stdoutText).toContain('WALG_ENABLED="true"'); }).pipe(Effect.provide(layer)); }); it.effect("treats --output pretty as identical to text mode (Glamour table)", () => { - const { layer, out } = setup({ goOutput: "pretty", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "pretty", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); }).pipe(Effect.provide(layer)); }); @@ -220,11 +223,11 @@ WalgEnabled = true it.effect("--output flag value wins over --output-format when both provided", () => { const { layer, out } = setup({ format: "json", - goOutput: "yaml", + outputFlag: "yaml", response: PITR_RESPONSE, }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); @@ -233,22 +236,25 @@ WalgEnabled = true it.effect("passes the resolved project ref into the listAllBackups URL", () => { const { layer, api } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId env", () => { + it.effect("uses the --remote= value over CommandSettings.projectId env", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.some(flagRef) }); + yield* backupsList({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); assertPermissionDrift({ command: "backups list", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -288,7 +294,7 @@ WalgEnabled = true const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/database/backups`, @@ -320,7 +326,7 @@ WalgEnabled = true yield* fs.makeDirectory(tempDir, { recursive: true }); yield* fs.writeFileString(path.join(tempDir, "project-ref"), fileRef); - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); }).pipe(Effect.provide(layer)); }); @@ -336,7 +342,9 @@ WalgEnabled = true return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsList({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + backupsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -346,9 +354,11 @@ WalgEnabled = true }); it.effect("fails with InvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = setup({ response: PITR_RESPONSE }); + const { layer } = setup({ response: PITR_RESPONSE, projectId: "BADREF" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.some("BADREF") })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); @@ -359,7 +369,9 @@ WalgEnabled = true it.effect("fails with BackupListUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: PITR_RESPONSE }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -372,7 +384,9 @@ WalgEnabled = true it.effect("fails with BackupListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail", response: PITR_RESPONSE }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -385,25 +399,29 @@ WalgEnabled = true it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503, response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); - it.effect( - "sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers (Go parity)", - () => { - const { layer, api } = setup({ - response: PITR_RESPONSE, - userAgent: "SupabaseCLI/1.42.0", - }); - return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); - const headers = api.requests[0]?.headers; - expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); - expect(headers?.["x-supabase-command"]).toBeUndefined(); - expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers", () => { + const { layer, api } = setup({ + response: PITR_RESPONSE, + userAgent: "SupabaseCLI/1.42.0", + }); + return Effect.gen(function* () { + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); + const headers = api.requests[0]?.headers; + expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); + expect(headers?.["x-supabase-command"]).toBeUndefined(); + expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); +}); + +describeHostedTargetSelection("backups list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, PITR_RESPONSE)), + run: (flags) => backupsList(flags), }); diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 82ed519c38..b0a3ef4867 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase backups restore` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -8,7 +10,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/project-ref` | plain text | when neither `--remote=` nor `SUPABASE_PROJECT_ID` is set | ## Files Written @@ -19,38 +21,43 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=` (or the deprecated `--project-ref=`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=` when no project is linked | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--remote=` is not given | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — restore initiated | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | -| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — restore initiated | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — no `--remote=`, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | +| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` / `--timestamp` → ``) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=` → the ref, a bare `--remote` → `true`, any other `--remote` value → ``; `--project-ref` → the ref when ref-shaped, else ``; `--timestamp` → ``) | ## Output @@ -78,6 +85,7 @@ One `result` NDJSON event on success with the project ref payload. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". - Known gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API (the underlying field has no lower bound). Resolving this requires an upstream OpenAPI spec change. -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. + +- **Consent.** Asks once, after the project is resolved and before the POST, with default No: `Restore the database to Unix timestamp (PITR)`. With no `--timestamp` the action reads `Start a PITR restore without a recovery time target`. diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index 8d72aa8819..c00007c752 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -3,18 +3,18 @@ import { Command, Flag } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { projectRefPicker } from "../../../config/remote-target.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), timestamp: Flag.Int("timestamp").pipe( Flag.withAlias("t"), Flag.withDescription("The recovery time target in seconds since epoch."), @@ -41,10 +41,12 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => backupsRestore(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "restore"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 86fde5e2ab..50b8345073 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -1,11 +1,12 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { RemoteTarget } from "../../../config/remote-target.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; -import { encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { BackupRestoreNetworkError, @@ -23,16 +24,21 @@ const mapRestoreError = mapHttpError({ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: BackupsRestoreFlags) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* RemoteTarget; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const { ref } = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget(flags); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: Option.isSome(flags.timestamp) + ? `Restore the database to Unix timestamp ${flags.timestamp.value} (PITR)` + : "Start a PITR restore without a recovery time target", + }); + const restoring = output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; yield* api.v1 @@ -43,16 +49,19 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac ); yield* restoring?.clear ?? Effect.void; - const goFmt = Option.getOrUndefined(goOutputFlag); + const outputFlagFormat = Option.getOrUndefined(outputFlag); // --output is ignored for restore except `json`, a TS-only structured payload; every other // value (including unset) writes the text line to stderr. - if (goFmt === "json") { - yield* output.raw(encodeGoJson({ message: "Started PITR restore", project_ref: ref })); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson({ message: "Started PITR restore", project_ref: ref })); return; } - if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { + if ( + outputFlagFormat === undefined && + (output.format === "json" || output.format === "stream-json") + ) { yield* output.success("Started PITR restore", { project_ref: ref }); return; } diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index b6e9590eb5..adc1eb3c2a 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,6 +1,6 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; +import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { @@ -10,10 +10,15 @@ import { mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -36,13 +41,22 @@ const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { interface SetupOpts { format?: "text" | "json" | "stream-json"; - goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; status?: number; network?: "fail"; } const tempRoot = useTempWorkdir("supabase-backups-restore-int-"); +const buildRuntime = (opts: Parameters[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Start a PITR restore without a recovery time target on project ${VALID_REF}\n`; + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -51,11 +65,11 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }); return { layer, out, api }; } @@ -65,6 +79,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -78,6 +93,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.some(1_707_407_047), }); @@ -91,14 +107,17 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("writes 'Started PITR restore: \\n' to stderr in text mode (Go parity)", () => { + it.effect("writes 'Started PITR restore: \\n' to stderr in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -107,6 +126,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -120,6 +140,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -128,10 +149,11 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("emits indented JSON to stdout for --output json (Go-compat)", () => { - const { layer, out } = setup({ goOutput: "json" }); + it.effect("emits indented JSON to stdout for --output json", () => { + const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -142,32 +164,36 @@ describe("backups restore integration", () => { }); it.effect( - "renders the stderr text line for --output {pretty,yaml,toml,env} (Go ignores --output)", + "renders the stderr text line for --output {pretty,yaml,toml,env} (--output is ignored)", () => { - const { layer, out } = setup({ goOutput: "yaml" }); + const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }, ); - it.effect("uses --project-ref flag over CommandSettings.projectId", () => { + it.effect("uses the --remote= value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ - projectRef: Option.some(flagRef), + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), timestamp: Option.none(), }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); assertPermissionDrift({ command: "backups restore", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -178,7 +204,11 @@ describe("backups restore integration", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -193,7 +223,11 @@ describe("backups restore integration", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -211,13 +245,15 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( - Effect.provide(layer), - ), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }).pipe(Effect.provide(layer)), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -251,7 +287,7 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -266,7 +302,11 @@ describe("backups restore integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }); + yield* backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }); expect(out.promptSelectCalls).toHaveLength(1); expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ @@ -286,6 +326,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.some(42), }); @@ -293,3 +334,19 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("backups restore", { + api: { response: { status: 201, body: null } }, + invoke: () => ({ + run: backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.some(1_707_407_047), + }), + }), +}); + +describeHostedTargetSelection("backups restore", { + respond: (request) => Effect.succeed(jsonResponse(request, 201, null)), + run: (flags) => backupsRestore({ ...flags, timestamp: Option.none() }), +}); diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 5702c4f148..3dfd263281 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -1,38 +1,40 @@ # `supabase bootstrap [template]` -`bootstrap` is a meta-orchestrator: it chains a workdir prompt → template fetch/download → -blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → +Target: account, consent default Yes — see [Command target](../../../docs/command-target.md) + +`bootstrap` is a meta-orchestrator: it chains a workdir prompt → hosted-mutation consent → +template fetch/download → blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → health poll → write `.env` → `db push` → start suggestion. Every step is native TypeScript, including the migration push (`dbPushCore`, shared with the standalone `supabase db push` command — see Notes). ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | -| `/.env.example` | dotenv | optional; merged into the generated `.env` | -| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`loadProjectEnv`), before config.toml validation and again inside `checkDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | -| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | -| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | -| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `resolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | -| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | -| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | -| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | -| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | -| `PGSSL*` PEM, `pg_service.conf` | PEM, libpq | native push step, linked project, when set; see `db push` | +| Path | Format | When | +| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | +| `/.env.example` | dotenv | optional; merged into the generated `.env` | +| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`loadProjectEnvValues`), before config.toml validation and again inside `checkDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | +| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | +| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | +| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `resolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | +| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | +| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | +| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | +| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | +| `PGSSL*` PEM, `pg_service.conf` | PEM, libpq | native push step, remote project, when set; see `db push` | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | blank/`scratch` path only (via `initProject`) | -| `/